Skip to content

Commit

Permalink
Merge pull request #7 from offtherailz/mbarto-user_sessions_plugin
Browse files Browse the repository at this point in the history
User sessions plugin improvements
  • Loading branch information
mbarto authored Apr 29, 2020
2 parents 9f752c3 + 0e1210b commit ad8e9f9
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 64 deletions.
32 changes: 18 additions & 14 deletions docs/developer-guide/user-sessions.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Implementing User Sessions

MapStore allows automatically saving / retrieving user sessions for every map.
When user sessions are enabled some information is automatically saved, so that it can be restored the next time
a user visits the same map.
Expand All @@ -10,25 +11,28 @@ It is also possible to save the session on a database, so it can be shared on di

The user session workflow works this way:

* a session is identified by the combination of the current map and user identifiers (so that a session exists for each user / map combination)
* a session is loaded from the store and if it exists, it overrides the standard map configuration partially; by default current map centering and zoom are overridden
* the session is automatically saved at a configurable interval
* a Burger Menu item allows restoring the session to the default map configuration
* a session is identified by the combination of the current map and user identifiers (so that a session exists for each user / map combination)
* a session is loaded from the store and if it exists, it overrides the standard map configuration partially; by default current map centering and zoom are overridden
* the session is automatically saved at a configurable interval
* a Burger Menu item allows restoring the session to the default map configuration

## Configuring user sessions

Since user session handling works very low level, its basic features needs to be configured at the `localConfig.json` level.
Then including or not including the plugin `UserSession` in your application context will determine the possibility to save (and so restore) the session.

## Configuring user sessions
Since user session handling works very low level, its basic features needs to be configured at the localConfig.json level.
A *userSessions* property is available for this. This is an object with the following properties:

* enabled: false / true
* saveFrequency: interval (in milliseconds) between saves
* provider: the name of the storage provider to use (defaults to browser)
* contextOnly: true / false, when true each MapStore context will share only one session, if false each context submap will have its own session
* `enabled`: false / true
* `saveFrequency`: interval (in milliseconds) between saves
* `provider`: the name of the storage provider to use (defaults to browser)
* `contextOnly`: true / false, when true each MapStore context will share only one session, if false each context submap will have its own session

MapStore includes 3 different providers by default:
MapStore includes 3 different providers you can set in `userSession.provider`:

* browser: default localStorage based
* server: database storage (based on MapStore backend services)
* serverbackup: combination of browser and server, with a configurable backupFrequency interval, so that browser saving it's more frequent than server one
* `browser`: default localStorage based
* `server`: database storage (based on MapStore backend services)
* `serverbackup`: combination of browser and server, with a configurable backupFrequency interval, so that browser saving it's more frequent than server one

You can also implement your own, by defining its API and registering it on the Providers object:

Expand Down
7 changes: 7 additions & 0 deletions web/client/actions/usersession.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SAVE_MAP_CONFIG = "USER_SESSION:ORIGINAL_CONFIG";
export const USER_SESSION_START_SAVING = "USER_SESSION:START_SAVING";
export const USER_SESSION_STOP_SAVING = "USER_SESSION:STOP_SAVING";
export const SET_USER_SESSION = "USER_SESSION:SET";
export const ENABLE_AUTO_SAVE = "USER_SESSION:ENABLE_AUTO_SAVE";

export const saveUserSession = () => ({type: SAVE_USER_SESSION});
export const userSessionSaved = (id, session) => ({type: USER_SESSION_SAVED, id, session});
Expand All @@ -28,6 +29,12 @@ export const userSessionStartSaving = () => ({type: USER_SESSION_START_SAVING});
export const userSessionStopSaving = () => ({type: USER_SESSION_STOP_SAVING});
export const saveMapConfig = (config) => ({type: SAVE_MAP_CONFIG, config});
export const setUserSession = (session) => ({type: SET_USER_SESSION, session});
/**
* Action to enable/disable the auto-save functionality.
* @param {boolean} enabled flag to enable/disable the auto-save for session
*/
export const enableAutoSave = (enabled) => ({ type: ENABLE_AUTO_SAVE, enabled });

export const loading = (value, name = "loading") => ({
type: USER_SESSION_LOADING,
name,
Expand Down
81 changes: 56 additions & 25 deletions web/client/epics/__tests__/context-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/
import Rx from 'rxjs';

import expect from 'expect';
import { testEpic } from './epicTestUtils';
import { testEpic, testCombinedEpicStream } from './epicTestUtils';
import ajax from '../../libs/ajax';

import { configureMap, configureError, LOAD_MAP_CONFIG } from "../../actions/config";
Expand Down Expand Up @@ -42,6 +43,7 @@ import { LOAD_USER_SESSION, userSessionLoaded, SET_USER_SESSION, USER_SESSION_ST

let mockAxios;


describe('context epics', () => {
const mapId = 1;
const contextId = 2;
Expand Down Expand Up @@ -101,46 +103,75 @@ describe('context epics', () => {
ConfigUtils.setConfigProp("userSessions", {
enabled: true
});
const actions = [];
createContextResponse();
const act = [
loadContext({ mapId, contextName })
];
const store = testEpic(loadContextAndMap, 10, act, ([loadingAction, sessionLoadAction,
clearMapTemplatesAction, loadMapAction, setUserSessionAction, userSessionSavingAction,
setResourceAction, setContextAction, loadFinishedAction, loadEndAction]) => {
const testActions = () => {
const [contextLoadAction, loadingAction, sessionLoadAction, userSessionLoadedAction,
clearMapTemplatesAction, loadMapAction, setUserSessionAction, userSessionSavingAction,
setResourceAction, setContextAction, mapConfigloadedAction, loadFinishedAction] = actions;
expect(contextLoadAction).toBeTruthy(); // emitted by the test
expect(loadingAction.type).toBe(LOADING);
expect(loadingAction.value).toBe(true);
expect(sessionLoadAction.type).toBe(LOAD_USER_SESSION);
expect(sessionLoadAction.name).toBe("2.1.Saitama");
expect(userSessionLoadedAction).toBeTruthy(); // emitted by the test
expect(clearMapTemplatesAction.type).toBe(CLEAR_MAP_TEMPLATES);
expect(loadMapAction.type).toBe(LOAD_MAP_CONFIG);
expect(setUserSessionAction.type).toBe(SET_USER_SESSION);
expect(userSessionSavingAction.type).toBe(USER_SESSION_START_SAVING);
expect(setResourceAction.type).toBe(SET_RESOURCE);
expect(setResourceAction.resource.canDelete).toBe(true); // check one random content of the resource
expect(setContextAction.type).toBe(SET_CURRENT_CONTEXT);
expect(mapConfigloadedAction).toBeTruthy(); // emitted by the test
expect(setContextAction.context.plugins.desktop).toExist(); // check context content
expect(loadFinishedAction.type).toBe(LOAD_FINISHED);
expect(loadEndAction.type).toBe(LOADING);
expect(loadEndAction.value).toBe(false);
done();
},
{
security: {
user: {
role: "ADMIN",
name: "Saitama"
// note load-end event is catched by stop epic
};
// TODO: these can be replaced with the effective epics that implement this functionality.
// simulate load user session
const controlEpic = action$ => Rx.Observable.merge(
// simulate load user session
action$
.ofType(LOAD_USER_SESSION)
.switchMap(() => Rx.Observable.of(userSessionLoaded(10, {
map: {},
context: {
userPlugins: []
}
})).delay(100)),
// simulate load map
action$
.ofType(LOAD_MAP_CONFIG)
.switchMap(() => Rx.Observable.of(configureMap({})).delay(10))
);
// copies the actions emitted in an array so we can check them at the end.
const spyEpic = a$ => a$.do(a => actions.push(a)).ignoreElements();

const startEpic = () => Rx.Observable.of(loadContext({ mapId, contextName }));
const stopEpic = action$ => action$.filter(({ value, type }) => type === LOADING && !value);
const mockStore = {
getState: () => ({
security: {
user: {
role: "ADMIN",
name: "Saitama"
}
}
}
});
setTimeout(() => store.dispatch(
userSessionLoaded(100, {
map: {},
context: {
userPlugins: []
})
};
testCombinedEpicStream(
[spyEpic, loadContextAndMap, startEpic, controlEpic],
stopEpic,
{
onNext: () => actions.push(),
onError: e => done(e),
onComplete: () => {
testActions();
done();
}
})), 100);

},
mockStore
);
});
/*
* check error actions
Expand Down
68 changes: 59 additions & 9 deletions web/client/epics/__tests__/epicTestUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,24 @@ module.exports = {
testEpic: (epic, count, action, callback, state = {}, done, withCompleteAction = false) => {
const actions = new Rx.Subject();
const actions$ = new ActionsObservable(actions);
const store = { getState: () => isFunction(state) ? state() : state, dispatch: (a) => {
actions.next(a);
}};
epic(actions$, store)[isFunction(count) ? "takeWhile" : "take"](count, true).concat( withCompleteAction ? Rx.Observable.of({ type: "EPIC_COMPLETED"}) : [])
const store = {
getState: () => isFunction(state) ? state() : state,
// subscribes to the actions and allow to react to them.
dispatch: (a) => {
actions.next(a);
}
};
epic(actions$, store)
[isFunction(count) ? "takeWhile" : "take"](count, true).concat( withCompleteAction ? Rx.Observable.of({ type: "EPIC_COMPLETED"}) : [])
.toArray()
.subscribe(done ? (x) => {
.subscribe((x) => {
try {
callback(x);
done();
done && done();
} catch (e) {
done(e);
done && done(e);
}
} : callback);
}, done ? e => done(e) : undefined);
if (action.length) {
action.map(act => actions.next(act));
} else {
Expand All @@ -45,5 +50,50 @@ module.exports = {
* @param {function} epic The epic to combine
* @param {Number} [timeout=1000] milliseconds to wait after emit the TEST_TIMEOUT action.
*/
addTimeoutEpic: (epic, timeout = 1000) => combineEpics(epic, () => Rx.Observable.timer(timeout).map(() => ({type: TEST_TIMEOUT, timeout})))
addTimeoutEpic: (epic, timeout = 1000) => combineEpics(epic, () => Rx.Observable.timer(timeout).map(() => ({type: TEST_TIMEOUT, timeout}))),
/**
* More general, but more complicated, test utility that allows to test epics combined.
* Differently from the testEpic, this function allow to combine and test more epics at once.
* You can add your own epics to the list in order to intercept events and simulate the real world
* interaction or to check the interactions between the two or more epics at the same time
* @param {function[]} epics the epics array to test.Typically you can pass the epic you want to test plus all epics for control
* (emit actions/check correct action emitted)
* @param {function} stopEpic function that returns an observable. When this observable returned emits 1 value the whole stream will complete.
* note: the name "epic" is because of the signature that is the the same of an epic, but it returns an observable, more in general.
* @param {object} handlers contains handlers `onNext` `onError` `onComplete` to associate to the events happened
* @param {object} store. A mock of a redux store.
* @example
* let actions = [];
* testcombinedEpicStream(
* [myEpic1, myEpic2, myMockEpicToSimulateControl],
* action$ => a$.filter(a => if(conditionOfEnd(e))),
* {
* onNext: a => actions.push(a), // note: here all the actions are included.
* onError: e => done(e),
* onComplete: () => {testActions(actions); done();}
* }
* )
*/
testCombinedEpicStream: (
epics,
stopEpic,
{
onNext = () => {},
onError = () => {},
onComplete = () => {}
} = {},
store = {getState: () => {}}, ) => {
const actions = new Rx.Subject();
const actions$ = new ActionsObservable(actions);
return combineEpics(...epics)(actions$, store)
.takeUntil(stopEpic(actions, store))
.subscribe(
e => {
onNext(e);
actions.next(e);
},
e => onError(e),
() => onComplete()
);
}
};
11 changes: 11 additions & 0 deletions web/client/epics/__tests__/usersession-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ describe('usersession Epics', () => {
sample1: "sample1",
sample2: "sample2",
id: "1",
usersession: {
autoSave: true
},
security: {
user: {
name: "myuser"
Expand Down Expand Up @@ -72,6 +75,14 @@ describe('usersession Epics', () => {
store.dispatch({ type: 'STOP' });
}, 100);
});
it('disable autoSave do not allow session saving', (done) => {
const store = testEpic(autoSaveSessionEpicCreator(10, () => ({ type: 'END' }), 'START', 'STOP', 10), (action) => action.type !== "END", { type: 'START' }, (actions) => {
expect(actions[0].type).toBe("EPIC_COMPLETED");
}, { ...initialState, usersession: { autoSave: false } }, done, true);
setTimeout(() => {
store.dispatch({ type: 'STOP' });
}, 100);
});
it('start, stop and restart user session save', (done) => {
let count = 0;
const store = testEpic(autoSaveSessionEpicCreator(10, () => ({type: 'END' + (count++)}), 'START', 'STOP'), (action) => action.type !== "END1", {type: 'START'}, (actions) => {
Expand Down
9 changes: 5 additions & 4 deletions web/client/epics/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const createSessionFlow = (mapId, contextName, action$, getState) => {
).flatMap(([id, data]) => {
const userName = userSelector(getState())?.name;
return Observable.of(loadUserSession(buildSessionName(id, mapId, userName))).merge(
action$.ofType(USER_SESSION_LOADED).flatMap(({session}) => {
action$.ofType(USER_SESSION_LOADED).take(1).switchMap(({session}) => {
const mapSession = session?.map && {
map: session.map
};
Expand All @@ -126,7 +126,7 @@ const createSessionFlow = (mapId, contextName, action$, getState) => {
Observable.of(setUserSession(session)),
Observable.of(userSessionStartSaving())
);
}).take(6)
})
);
});
};
Expand All @@ -139,8 +139,9 @@ const createSessionFlow = (mapId, contextName, action$, getState) => {
export const loadContextAndMap = (action$, { getState = () => { } } = {}) =>
action$.ofType(LOAD_CONTEXT).switchMap(({ mapId, contextName }) => {
const sessionsEnabled = userSessionEnabledSelector(getState());
const flow = sessionsEnabled ? createSessionFlow(mapId, contextName, action$, getState) :
Observable.merge(
const flow = sessionsEnabled
? createSessionFlow(mapId, contextName, action$, getState)
: Observable.merge(
Observable.of(clearMapTemplates()),
getResourceIdByName('CONTEXT', contextName)
.switchMap(id => createContextFlow(id, null, getState)).catch(e => {throw new ContextError(e); }),
Expand Down
10 changes: 6 additions & 4 deletions web/client/epics/usersession.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {loadMapConfig} from "../actions/config";
import {userSelector} from '../selectors/security';
import { wrapStartStop } from '../observables/epics';
import {originalConfigSelector, userSessionNameSelector, userSessionIdSelector,
userSessionSaveFrequencySelector, userSessionToSaveSelector} from "../selectors/usersession";
userSessionSaveFrequencySelector, userSessionToSaveSelector, isAutoSaveEnabled} from "../selectors/usersession";

const {getSession, writeSession, removeSession} = UserSession;

Expand Down Expand Up @@ -83,9 +83,11 @@ export const saveUserSessionEpicCreator = (sessionSelector = userSessionToSaveSe
*/
export const autoSaveSessionEpicCreator = (frequency, finalAction, startAction = USER_SESSION_START_SAVING, endAction = USER_SESSION_STOP_SAVING) =>
(action$, store) => action$.ofType(startAction)
.switchMap(() => Rx.Observable.interval(frequency || userSessionSaveFrequencySelector(store.getState()))
.switchMap(() => Rx.Observable.of(saveUserSession()))
.takeUntil(action$.ofType(endAction)).concat(finalAction ? Rx.Observable.of(finalAction()) : Rx.Observable.empty())
.switchMap(() =>
Rx.Observable.interval(frequency || userSessionSaveFrequencySelector(store.getState()))
.filter(() => isAutoSaveEnabled(store.getState()))
.switchMap(() => Rx.Observable.of(saveUserSession()))
.takeUntil(action$.ofType(endAction)).concat(finalAction ? Rx.Observable.of(finalAction()) : Rx.Observable.empty())
);

/**
Expand Down
6 changes: 3 additions & 3 deletions web/client/localConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
{"name": "geostorymode", "path": "geostory.mode"},
{"name": "featuregridmode", "path": "featuregrid.mode"}],
"userSessions": {
"enabled": false
"enabled": true
},
"projectionDefs": [],
"initialState": {
Expand Down Expand Up @@ -179,7 +179,7 @@
},
"tools": ["locate"]
}
}, "UserSession", "Version", "DrawerMenu",
}, "Version", "DrawerMenu",
{
"name": "BackgroundSelector",
"cfg": {
Expand Down Expand Up @@ -273,7 +273,7 @@
},
"FeedbackMask"
],
"desktop": [ "UserSession", "Details",
"desktop": [ "Details",
{
"name": "Map",
"cfg": {
Expand Down
Loading

0 comments on commit ad8e9f9

Please sign in to comment.