Commit 62335cc7 authored by Jakub Beránek's avatar Jakub Beránek
Browse files

ENH: add optimistic updates for views

parent ba061700
......@@ -47,10 +47,10 @@ export class CrudHandler<T extends Identifiable, D extends DAO>
token
}).pipe(map(() => true));
}
update(token: string, item: T, args: {}): Observable<boolean>
update(token: string, item: T, args: {}): Observable<T>
{
return this.requestManager.request(`${this.url}/${item.id}`, 'PATCH', args, {
token
}).pipe(map(() => true));
}).pipe(map(() => item));
}
}
......@@ -51,7 +51,7 @@ export class RestClient implements SnailClient
{
return this.userCrud.loadOne(token, id);
}
updateUser(token: string, user: User): Observable<boolean>
updateUser(token: string, user: User): Observable<User>
{
return this.userCrud.update(token, user, serializeUser(user));
}
......@@ -90,7 +90,7 @@ export class RestClient implements SnailClient
return projects[0];
}));
}
updateProject(token: string, project: Project): Observable<boolean>
updateProject(token: string, project: Project): Observable<Project>
{
return this.projectCrud.update(token, project, serializeProject(project));
}
......@@ -181,7 +181,7 @@ export class RestClient implements SnailClient
{
return this.viewCrud.delete(token, view);
}
updateView(token: string, view: View): Observable<boolean>
updateView(token: string, view: View): Observable<View>
{
return this.viewCrud.update(token, view, serializeView(view));
}
......
......@@ -15,14 +15,14 @@ export interface BatchedMeasurements
export interface SnailClient
{
loadUser(token: string, id: string): Observable<User>;
updateUser(token: string, user: User): Observable<boolean>;
updateUser(token: string, user: User): Observable<User>;
loginUser(username: string, password: string): Observable<{ user: User, token: string }>;
changePassword(token: string, oldPassword: string, newPassword: string): Observable<boolean>;
createProject(token: string, project: Project): Observable<Project>;
loadProjects(token: string): Observable<Project[]>;
loadProject(token: string, name: string): Observable<Project>;
updateProject(token: string, project: Project): Observable<boolean>;
updateProject(token: string, project: Project): Observable<Project>;
regenerateUploadToken(token: string, project: Project): Observable<string>;
......@@ -37,5 +37,5 @@ export interface SnailClient
loadViews(token: string, project: Project): Observable<View[]>;
createView(token: string, project: Project, view: View): Observable<View>;
deleteView(token: string, view: View): Observable<boolean>;
updateView(token: string, view: View): Observable<boolean>;
updateView(token: string, view: View): Observable<View>;
}
......@@ -37,22 +37,22 @@ function getMeasurements(batch: BatchedMeasurements, view: View): Measurement[]
return batch.views[view.id].map(id => batch.measurements[id]);
}
const reloadViews = createRequestEpic(reloadViewMeasurementsAction, (action, state, deps) => {
const reloadViews = createRequestEpic(reloadViewMeasurementsAction, (action, store, deps) => {
const rangeFilter = action.payload;
const state = store.value;
const views = getViews(state);
const dirtyViews = getDirtyViews(views, rangeFilter);
if (dirtyViews.length === 0) return of(views);
const dirtyViewSet = new Set(dirtyViews);
const dirtyViewSet = new Set(dirtyViews.map(v => v.id));
const token = getToken(state);
const project = getSelectedProject(state);
function mapViews<T>(t: T, extractMeasurements: (t: T, view: View) => Measurement[])
{
return views.map(v => {
if (!dirtyViewSet.has(v)) return v;
return getViews(store.value).map(v => {
if (!dirtyViewSet.has(v.id)) return v;
const measurements = extractMeasurements(t, v);
insertMeasurementsRecord(v, rangeFilter, measurements);
......@@ -73,7 +73,7 @@ const reloadViews = createRequestEpic(reloadViewMeasurementsAction, (action, sta
return deps.client.loadMeasurementsBatched(token, project, dirtyViews, rangeFilter)
.pipe(map(batched => mapViews(batched, (m, v) => getMeasurements(m, v))));
}
});
}, true);
const handleViewGridChartSelect: AppEpic = (action$, store) =>
action$.pipe(
......@@ -110,7 +110,7 @@ const handleViewSelect: AppEpic = (action$, store) =>
const reloadDatasetsAfterViewChange: AppEpic = (action$, store) =>
action$.pipe(
ofAction(ViewActions.update.done),
ofActions([ViewActions.create.done, ViewActions.update.done]),
map(() => reloadViewMeasurementsAction.started(getRangeFilter(store.value)))
);
......
......@@ -4,8 +4,8 @@ import {getSelectedProject} from '../project/reducers';
import {getToken} from '../user/reducers';
import {loadGlobalMeasurements} from './actions';
const loadMeasurements = createRequestEpic(loadGlobalMeasurements, (action, state, deps) =>
deps.client.loadMeasurements(getToken(state), getSelectedProject(state), null, action.payload)
const loadMeasurements = createRequestEpic(loadGlobalMeasurements, (action, store, deps) =>
deps.client.loadMeasurements(getToken(store.value), getSelectedProject(store.value), null, action.payload)
);
export const pageEpics = combineEpics(
......
......@@ -11,15 +11,16 @@ import {getRangeFilter} from '../reducers';
import {deleteAllMeasurementsAction, deleteMeasurementAction, loadMeasurementsAction} from './actions';
import {getMeasurementsPageView} from './reducer';
const loadMeasurements = createRequestEpic(loadMeasurementsAction, (action, state, deps) => {
const loadMeasurements = createRequestEpic(loadMeasurementsAction, (action, store, deps) => {
const state = store.value;
const project = getSelectedProject(state);
const rangeFilter = getRangeFilter(state);
return deps.client.loadMeasurements(getToken(state), project, getMeasurementsPageView(state), rangeFilter);
});
const deleteMeasurement = createRequestEpic(deleteMeasurementAction, (action, state, deps) => {
return deps.client.deleteMeasurement(getToken(state), action.payload);
const deleteMeasurement = createRequestEpic(deleteMeasurementAction, (action, store, deps) => {
return deps.client.deleteMeasurement(getToken(store.value), action.payload);
});
const deleteAllMeasurements: AppEpic = (action$, store, deps) =>
......
......@@ -11,16 +11,16 @@ import {getToken} from '../user/reducers';
import {deselectProject, ProjectActions, regenerateUploadToken, selectProject} from './actions';
import {getProjectById, getProjects} from './reducers';
const loadProjectsEpic = createRequestEpic(ProjectActions.load, (action, state, deps) =>
deps.client.loadProjects(getToken(state))
const loadProjectsEpic = createRequestEpic(ProjectActions.load, (action, store, deps) =>
deps.client.loadProjects(getToken(store.value))
);
const createProjectEpic = createRequestEpic(ProjectActions.create, (action, state, deps) =>
deps.client.createProject(getToken(state), action.payload)
const createProjectEpic = createRequestEpic(ProjectActions.create, (action, store, deps) =>
deps.client.createProject(getToken(store.value), action.payload)
);
const updateProjectEpic = createRequestEpic(ProjectActions.update, (action, state, deps) =>
deps.client.updateProject(getToken(state), action.payload)
const updateProjectEpic = createRequestEpic(ProjectActions.update, (action, store, deps) =>
deps.client.updateProject(getToken(store.value), action.payload)
);
const initSessionAfterProjectSelect: AppEpic = action$ =>
......@@ -38,8 +38,9 @@ const initSessionAfterProjectSelect: AppEpic = action$ =>
)
);
const regenerateUploadTokenEpic = createRequestEpic(regenerateUploadToken, (action, state, deps) =>
deps.client.regenerateUploadToken(getToken(state), getProjectById(getProjects(state), action.payload.project))
const regenerateUploadTokenEpic = createRequestEpic(regenerateUploadToken, (action, store, deps) =>
deps.client.regenerateUploadToken(getToken(store.value), getProjectById(getProjects(store.value),
action.payload.project))
);
const goToProjectSelectionAfterUnselecting: AppEpic = (action$) =>
......
......@@ -9,14 +9,14 @@ import {initUserSession} from '../actions';
import {changePasswordAction, loginUserAction, UserActions} from './actions';
import {getToken} from './reducers';
const loadUserEpic = createRequestEpic(UserActions.loadOne, (action, state, deps) =>
deps.client.loadUser(getToken(state), action.payload)
const loadUserEpic = createRequestEpic(UserActions.loadOne, (action, store, deps) =>
deps.client.loadUser(getToken(store.value), action.payload)
);
const updateUserEpic = createRequestEpic(UserActions.update, (action, state, deps) =>
deps.client.updateUser(getToken(state), action.payload)
const updateUserEpic = createRequestEpic(UserActions.update, (action, store, deps) =>
deps.client.updateUser(getToken(store.value), action.payload)
);
const loginUserEpic = createRequestEpic(loginUserAction, (action, state, deps) =>
const loginUserEpic = createRequestEpic(loginUserAction, (action, store, deps) =>
deps.client.loginUser(action.payload.username, action.payload.password).pipe(
catchError(error => {
if (error instanceof ApiError && error.status === 403)
......@@ -27,8 +27,8 @@ const loginUserEpic = createRequestEpic(loginUserAction, (action, state, deps) =
return throwError(error);
}))
);
const changePasswordEpic = createRequestEpic(changePasswordAction, (action, state, deps) =>
deps.client.changePassword(getToken(state), action.payload.oldPassword, action.payload.newPassword)
const changePasswordEpic = createRequestEpic(changePasswordAction, (action, store, deps) =>
deps.client.changePassword(getToken(store.value), action.payload.oldPassword, action.payload.newPassword)
);
const initUserSessionAfterLogin: AppEpic = action$ =>
......
import {combineEpics} from 'redux-observable';
import {createRequestEpic} from '../../../util/request';
import {of} from 'rxjs';
import {createRequestEpic, createRequestEpicOptimistic} from '../../../util/request';
import {getSelectedProject} from '../project/reducers';
import {getToken} from '../user/reducers';
import {ViewActions} from './actions';
import {getViewById, getViews} from './reducers';
const loadViews = createRequestEpic(ViewActions.load, (action, state, deps) =>
deps.client.loadViews(getToken(state), getSelectedProject(state))
const loadViews = createRequestEpic(ViewActions.load, (action, store, deps) =>
deps.client.loadViews(getToken(store.value), getSelectedProject(store.value))
);
const createView = createRequestEpic(ViewActions.create, (action, state, deps) =>
deps.client.createView(getToken(state), getSelectedProject(state), action.payload)
const createView = createRequestEpic(ViewActions.create, (action, store, deps) =>
deps.client.createView(getToken(store.value), getSelectedProject(store.value), action.payload)
);
const updateView = createRequestEpic(ViewActions.update, (action, state, deps) =>
deps.client.updateView(getToken(state), action.payload)
const updateView = createRequestEpicOptimistic(ViewActions.update, (action, store, deps) =>
deps.client.updateView(getToken(store.value), action.payload),
(action) => action.payload,
(action, state) => of(ViewActions.update.done({
params: action.payload,
result: getViewById(getViews(state), action.payload.id)
}))
);
const deleteView = createRequestEpic(ViewActions.delete, (action, state, deps) =>
deps.client.deleteView(getToken(state), action.payload)
const deleteView = createRequestEpicOptimistic(ViewActions.delete, (action, store, deps) =>
deps.client.deleteView(getToken(store.value), action.payload),
() => true,
(action) => of(ViewActions.create.done({
params: action.payload,
result: action.payload
}))
);
export const viewEpics = combineEpics(
......
import {createStore} from 'redux';
import {reducerWithInitialState} from 'typescript-fsa-reducers';
import {createCrudActions, createCrudReducer} from '../util/crud';
import {createRequest, Request} from '../util/request';
describe('createCrudReducer', () => {
interface Item {
id: string;
data: number;
}
interface State {
items: Item[];
request: Request;
}
const actions = createCrudActions<Item>('test');
const createReducer = <P, S, E>(state: Partial<State> = {}) =>
{
let reducer = reducerWithInitialState<State>({
items: [] as Item[],
request: createRequest(),
...state
});
return createCrudReducer(reducer, actions, 'items', (s: State) => s.request).build();
};
it('Replaces item array after load', () => {
const reducer = createReducer({
items: [{ id: '1', data: 5 }]
});
const store = createStore(reducer);
const items = [{ id: '2', data: 8 }];
store.dispatch(
actions.load.done({
params: {},
result: items
})
);
expect(store.getState().items).toEqual(items);
});
it('Adds new item after create', () => {
const items = [{ id: '1', data: 5 }, { id: '2', data: 3 }];
const reducer = createReducer({ items });
const store = createStore(reducer);
const item = { id: '3', data: 8 };
store.dispatch(
actions.create.done({
params: item,
result: item
})
);
expect(store.getState().items).toEqual([...items, item]);
});
it('Updates only the correct item after update', () => {
const items = [{ id: '1', data: 5 }, { id: '2', data: 3 }, { id: '3', data: 4 }];
const reducer = createReducer({ items });
const store = createStore(reducer);
const item = { id: '2', data: 8 };
store.dispatch(
actions.update.done({
params: item,
result: item
})
);
expect(store.getState().items).toEqual([
items[0],
item,
items[2]
]);
});
it('Delete the correct item after delete', () => {
const items = [{ id: '1', data: 5 }, { id: '2', data: 3 }, { id: '3', data: 4 }];
const reducer = createReducer({ items });
const store = createStore(reducer);
const item = { id: '2', data: 8 }; // identity is recognized by id
store.dispatch(
actions.delete.done({
params: item,
result: true
})
);
expect(store.getState().items).toEqual([
items[0],
items[2]
]);
});
});
import {ActionsObservable, StateObservable} from 'redux-observable';
import {Observable, of, Subject, throwError} from 'rxjs';
import {delay, map} from 'rxjs/operators';
import {TestScheduler} from 'rxjs/testing';
import actionCreatorFactory, {Action} from 'typescript-fsa';
import {ApiError} from '../lib/errors/api';
import {AppEpic} from '../state/app/app-epic';
import {ServiceContainer} from '../state/app/di';
import {AppState} from '../state/app/reducers';
import {clearSession} from '../state/session/actions';
import {createRequestEpic, createRequestEpicOptimistic} from '../util/request';
function testEpic(epic: AppEpic,
input: (hot: typeof TestScheduler.prototype.createHotObservable) => Observable<{}>,
marble: string,
marbleData: {},
state: AppState = null,
deps: ServiceContainer = {} as ServiceContainer)
{
const testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
const store = new StateObservable(Subject.create(), state);
testScheduler.run(({ hot, cold, expectObservable }) => {
const output$ = epic(input(hot) as ActionsObservable<Action<{}>>, store, deps);
expectObservable(output$).toBe(marble, marbleData);
});
}
const factory = actionCreatorFactory('test');
const action = factory.async<string, number>('a1');
describe('createRequestEpic', () =>
{
it('It returns done action when correct result is received', () =>
{
testEpic(
createRequestEpic(action, () => of(5)),
hot => hot('a', {
a: action.started('x')
}),
'a',
{
a: action.done({
params: 'x',
result: 5
})
}
);
});
it('It returns failed action when an error is thrown', () =>
{
testEpic(
createRequestEpic(action, () => throwError('error')),
hot => hot('a', {
a: action.started('x')
}),
'a',
{
a: action.failed({
params: 'x',
error: 'error'
})
}
);
});
it('It ignores subsequent actions when refreshRequests is false', () =>
{
testEpic(
createRequestEpic(action, () => of(5).pipe(delay(2)), false),
hot => hot('aa', {
a: action.started('x')
}),
'--a',
{
a: action.done({
params: 'x',
result: 5
})
}
);
});
it('It refreshes actions when refreshRequests is true', () =>
{
testEpic(
createRequestEpic(action, () => of(5).pipe(delay(2)), true),
hot => hot('ab', {
a: action.started('x'),
b: action.started('y')
}),
'---a',
{
a: action.done({
params: 'y',
result: 5
})
}
);
});
it('It clears the session after a HTTP 401 error', () =>
{
const error = new ApiError(401, 'error');
testEpic(
createRequestEpic(action, () => throwError(error), true),
hot => hot('a', {
a: action.started('x')
}),
'(ab)',
{
a: action.failed({
params: 'x',
error
}),
b: clearSession()
}
);
});
});
describe('createRequestEpicOptimistic', () =>
{
it('It immediately returns optimistic done', () =>
{
testEpic(
createRequestEpicOptimistic(action, () => of(5).pipe(delay(2)),
() => 3,
(a) => of(action.done({ params: a.payload, result: 4 }))
),
hot => hot('a', {
a: action.started('x')
}),
'a-b',
{
a: action.done({
params: 'x',
result: 3
}),
b: action.done({
params: 'x',
result: 5
})
}
);
});
it('It reverts the original state when a failure occurs', () =>
{
testEpic(
createRequestEpicOptimistic(action, () => of(5).pipe(
delay(2),
map(() => { throw 'error'; })
),
() => 3,
(a) => of(action.done({ params: a.payload, result: 4 }))
),
hot => hot('a', {
a: action.started('x')
}),
'a-(bc)',
{
a: action.done({
params: 'x',
result: 3
}),
b: action.done({
params: 'x',
result: 4
}),
c: action.failed({
params: 'x',
error: 'error'
})
}
);
});
});
import moment from 'moment';
import {createStore} from 'redux';
import {ActionsObservable, StateObservable} from 'redux-observable';
import {of as observableOf, throwError as observableThrowError} from 'rxjs';
import actionCreatorFactory, {Action, Failure, Success} from 'typescript-fsa';
import {reducerWithInitialState} from 'typescript-fsa-reducers';
import {selectActiveRequest} from '../components/global/request/multi-request-component';
import {AppState} from '../state/app/reducers';
import {createRequest, createRequestEpic, hookRequestActions, mapRequestToActions} from '../util/request';
import {createRequest, hookRequestActions, mapRequestToActions} from '../util/request';
const factory = actionCreatorFactory('request-test');
const creator = factory.async<string, number>('action');
......@@ -54,26 +52,6 @@ describe('mapRequestToActions', () =>
});
});
describe('createRequestEpic', () => {
it('Starts request after started action', done => {
const action$ = ActionsObservable.of({
type: creator.started.type,
payload: 'test'
});
const epic = createRequestEpic(creator, (action) => {
expect(action.payload).toEqual('test');
return observableOf(0);
});
epic(action$, {
value: {}
} as StateObservable<AppState>, null).subscribe(() => {
done();
});
});
});
describe('hookRequestActions', () => {
const createReducer = () =>
{
......@@ -88,7 +66,7 @@ describe('hookRequestActions', () => {
};
const makeStore = () => createStore(createReducer());
it('Set\'s request loading to true after start', () => {
it(`Set's request loading to true after start`, () => {