Skip to content

Commit

Permalink
Merge branch 'master' into modules-select-feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
taneliang authored Jan 9, 2018
2 parents 9691dc0 + 9091543 commit 5ce199d
Show file tree
Hide file tree
Showing 13 changed files with 507 additions and 25 deletions.
12 changes: 12 additions & 0 deletions www/src/js/actions/undoHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @flow
import type { FSA } from 'types/redux';

export const UNDO = 'UNDO';
export function undo(): FSA {
return { type: UNDO, payload: null };
}

export const REDO = 'REDO';
export function redo(): FSA {
return { type: REDO, payload: null };
}
14 changes: 11 additions & 3 deletions www/src/js/reducers/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,23 @@ function app(state: AppState = defaultAppState(), action: FSA): AppState {
};
}

// Since the current item cannot give way, we discard the current
// item if it can be discarded
// Since the displayed item cannot give way, we
// discard the new item if possible
if (action.payload.overwritable) {
return state;
}

// Fallback to queuing the next item up
// Since both can't be discarded, priority notification
// gets displayed immediately
if (action.payload.priority) {
return {
...state,
notifications: [action.payload, ...state.notifications],
};
}
}

// Fallback to queuing the next item up
return {
...state,
notifications: [...state.notifications, action.payload],
Expand Down
34 changes: 34 additions & 0 deletions www/src/js/reducers/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,40 @@ describe('notification reducers', () => {
});
});

test('allow new priority notification to trump existing ones', () => {
let state = appInitialState;
state = reducer(
state,
openNotification('New notification', {
overwritable: true,
priority: true,
}),
);

// Expect overwritable priority notification to be overwritten
state = reducer(state, openNotification('Second notification'));
expect(state.notifications).toHaveLength(1);
expect(state.notifications[0]).toMatchObject({ message: 'Second notification' });

// Expect priority notification to be inserted at the front of the queue
const pNotif3 = { message: 'Third notification', priority: true };
state = reducer(state, openNotification(pNotif3.message, { priority: pNotif3.priority }));
expect(state.notifications).toHaveLength(2);
expect(state.notifications[0]).toMatchObject(pNotif3);

// Expect new, overwritable, priority notification to be discarded
// if non-overwritable notification is in line
state = reducer(
state,
openNotification('Fourth notification', {
overwritable: true,
priority: true,
}),
);
expect(state.notifications).toHaveLength(2);
expect(state.notifications[0]).toMatchObject(pNotif3);
});

test('pop notifications', () => {
// Pop empty queue
expect(reducer(appInitialState, popNotification()).notifications).toEqual([]);
Expand Down
16 changes: 15 additions & 1 deletion www/src/js/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { TimetableConfig } from 'types/timetables';
import type { Requests, SettingsState, AppState, ModuleFinderState } from 'types/reducers';
import type { ModuleBank } from 'reducers/moduleBank';
import type { VenueBank } from 'reducers/venueBank';
import type { UndoHistoryState } from 'reducers/undoHistory';

import { REMOVE_MODULE, SET_TIMETABLE } from 'actions/timetables';

import requests from './requests';
import moduleBank from './moduleBank';
Expand All @@ -13,6 +16,7 @@ import app from './app';
import theme from './theme';
import settings from './settings';
import moduleFinder from './moduleFinder';
import createUndoReducer from './undoHistory';

export type State = {
moduleBank: ModuleBank,
Expand All @@ -23,13 +27,21 @@ export type State = {
theme: Object,
settings: SettingsState,
moduleFinder: ModuleFinderState,
undoHistory: UndoHistoryState,
};

// $FlowFixMe: State default is delegated to its child reducers.
const defaultState: State = {};
const undoReducer = createUndoReducer({
limit: 1,
reducerName: 'undoHistory',
actionsToWatch: [REMOVE_MODULE, SET_TIMETABLE],
whitelist: ['timetables', 'theme.colors'],
});

export default function(state: State = defaultState, action: FSA): State {
return {
// Update every reducer except the undo reducer
const newState: State = {
moduleBank: moduleBank(state.moduleBank, action),
venueBank: venueBank(state.venueBank, action),
requests: requests(state.requests, action),
Expand All @@ -38,5 +50,7 @@ export default function(state: State = defaultState, action: FSA): State {
theme: theme(state.theme, action),
settings: settings(state.settings, action),
moduleFinder: moduleFinder(state.moduleFinder, action),
undoHistory: state.undoHistory,
};
return undoReducer(state, newState, action);
}
6 changes: 3 additions & 3 deletions www/src/js/reducers/timetables.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { FSA } from 'types/redux';
import type { ClassNo, LessonType } from 'types/modules';
import type { ModuleLessonConfig, TimetableConfig, SemTimetableConfig } from 'types/timetables';

import _ from 'lodash';
import { get, omit } from 'lodash';

import { ADD_MODULE, REMOVE_MODULE, CHANGE_LESSON, SET_TIMETABLE } from 'actions/timetables';
import { SET_EXPORTED_DATA } from 'actions/export';
Expand Down Expand Up @@ -41,7 +41,7 @@ function semTimetable(
state: SemTimetableConfig = defaultSemTimetableConfig,
action: FSA,
): SemTimetableConfig {
const moduleCode = _.get(action, 'payload.moduleCode');
const moduleCode = get(action, 'payload.moduleCode');
if (!moduleCode) return state;

switch (action.type) {
Expand All @@ -51,7 +51,7 @@ function semTimetable(
[moduleCode]: action.payload.moduleLessonConfig,
};
case REMOVE_MODULE:
return _.omit(state, [moduleCode]);
return omit(state, [moduleCode]);
case CHANGE_LESSON:
return {
...state,
Expand Down
116 changes: 116 additions & 0 deletions www/src/js/reducers/undoHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// @flow
import type { FSA } from 'types/redux';

import { pick, takeRight, set, get, last } from 'lodash';
import { UNDO, REDO } from 'actions/undoHistory';

export type UndoHistoryConfig = {
reducerName: string,
limit?: number,
actionsToWatch: string[],
whitelist: string[],
};

export type UndoHistoryState = {
past: Object[],
present: ?Object,
future: Object[],
};

// Call the reducer with empty action to populate the initial state
const initialState: UndoHistoryState = {
past: [],
present: undefined, // Don't pretend to know the present
future: [],
};

// Update undo history using the action and app states
// Basically a reducer but not really, as it needs to know the previous state.
// Passing state in even though state === presentAppState[config.reducerName] as the "reducer"
// doesn't need to know that.
export function computeUndoStacks<T: Object>(
state: UndoHistoryState = initialState,
action: FSA,
previousAppState: T,
presentAppState: T,
config: UndoHistoryConfig,
): UndoHistoryState {
const { past, present, future } = state;

// If action is undo/redoable, store state
if (config.actionsToWatch.includes(action.type)) {
// Append actual present to past, and drop history past config.limit
// Limit only enforced here since undo/redo only shift the history around without adding new history
const appendedPast = [...past, pick(previousAppState, config.whitelist)];
const newPast = 'limit' in config ? takeRight(appendedPast, config.limit) : appendedPast;

return {
past: newPast,
present: pick(presentAppState, config.whitelist),
future: [],
};
}

switch (action.type) {
case UNDO: {
// Abort if no past, or present is unknown
if (past.length === 0 || !present) return state;
const previous = last(past);
const newPast = past.slice(0, past.length - 1);
return {
past: newPast,
present: previous,
future: [present, ...future],
};
}
case REDO: {
// Abort if no future, or present is unknown
if (future.length === 0 || !present) return state;
const next = future[0];
const newFuture = future.slice(1);
return {
past: [...past, present],
present: next,
future: newFuture,
};
}
default: {
return state;
}
}
}

// Copy all keyPaths in present into a new copy of state
export function mergePresent<T: Object>(state: T, present: Object, keyPaths: string[]): T {
const newState = { ...state };
keyPaths.forEach((path) => {
const presentValue = get(present, path);
if (presentValue) set(newState, path, presentValue);
});
return newState;
}

// Given a config object, returns function which compute new state after
// undoing/redoing/storing present as required by action.
export default function createUndoReducer(config: UndoHistoryConfig) {
return <T: Object>(previousState: T, presentState: T, action: FSA) => {
// Calculate un/redone history
const undoHistoryState = presentState[config.reducerName];
const updatedHistory = computeUndoStacks(
undoHistoryState,
action,
previousState,
presentState,
config,
);
const updatedState = { ...presentState, [config.reducerName]: updatedHistory };

// Applies undo and redo actions on overall app state
// Applies updatedHistory.present to state if action.type === {UNDO,REDO}
// Assumes updatedHistory.present is the final present state
if ((action.type === UNDO || action.type === REDO) && updatedHistory.present) {
return mergePresent(updatedState, updatedHistory.present, config.whitelist);
}
return updatedState;
};
}
Loading

0 comments on commit 5ce199d

Please sign in to comment.