Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User sessions plugin improvements #7

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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