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

refactor: make history reducer reusable #85

Merged
merged 1 commit into from
Jan 21, 2024
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
40 changes: 25 additions & 15 deletions apps/client/src/services/canvas/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,21 @@ import type { PayloadAction } from '@reduxjs/toolkit';
export type CanvasSliceState = {
copiedNodes: NodeObject[];
} & AppState['page'];

export type CanvasAction = (typeof canvasActions)[keyof typeof canvasActions];
export type CanvasActionType = CanvasAction['type'];
export type ActionMeta = {
receivedFromWS?: boolean;
broadcast?: boolean;
duplicate?: boolean;
selectNodes?: boolean;
};

export const prepareMeta = <T = undefined>(
payload: T = undefined as T,
meta?: ActionMeta,
) => {
return { payload, meta };
};

export const initialState: CanvasSliceState = {
nodes: [],
Expand All @@ -36,20 +48,6 @@ export const initialState: CanvasSliceState = {
},
};

export type ActionMeta = {
receivedFromWS?: boolean;
broadcast?: boolean;
duplicate?: boolean;
selectNodes?: boolean;
};

export const prepareMeta = <T = undefined>(
payload: T = undefined as T,
meta?: ActionMeta,
) => {
return { payload, meta };
};

export const canvasSlice = createSlice({
name: 'canvas',
initialState,
Expand Down Expand Up @@ -236,4 +234,16 @@ export const selectPastHistory = (state: RootState) => state.canvas.past;
export const selectFutureHistory = (state: RootState) => state.canvas.future;

export const canvasActions = canvasSlice.actions;

export const ignoredActionsInHistory = [
canvasActions.setToolType,
canvasActions.setStageConfig,
canvasActions.set,
canvasActions.setSelectedNodeIds,
canvasActions.selectAllNodes,
canvasActions.unselectAllNodes,
canvasActions.copyNodes,
canvasActions.setCurrentNodeStyle,
] as const;

export default canvasSlice.reducer;
120 changes: 78 additions & 42 deletions apps/client/src/stores/reducers/__tests__/history.test.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,129 @@
import reducer, { type CanvasHistoryState, historyActions } from '../history';
import canvasReducer, {
initialState as initialCanvasState,
} from '@/services/canvas/slice';
import { nodesGenerator } from '@/test/data-generators';
import historyReducer, { historyActions } from '../history';
import { createAction, createReducer } from '@reduxjs/toolkit';
import type { HistoryState } from '../history';
import { simpleObjectsGenerator } from '@/test/data-generators';

type State = {
objects: ReturnType<typeof simpleObjectsGenerator>;
type: 'foo' | 'bar';
};

const initialState: State = { objects: [], type: 'foo' };

const addObjects = createAction<State['objects']>('addObjects');
const setType = createAction<State['type']>('setType');
const actionsToIgnore = [setType] as const;

const testReducer = createReducer(initialState, (builder) => {
builder
.addCase(addObjects, (state, action) => {
state.objects.push(...action.payload);
})
.addCase(setType, (state, action) => {
state.type = action.payload;
});
});

describe('history reducer', () => {
const historyReducer = reducer(canvasReducer);
const reducer = historyReducer(testReducer, initialState, actionsToIgnore);

const initialState: CanvasHistoryState = {
past: [
{ ...initialCanvasState, nodes: nodesGenerator(1) },
{ ...initialCanvasState, nodes: nodesGenerator(2) },
],
present: initialCanvasState,
future: [{ ...initialCanvasState, nodes: nodesGenerator(3) }],
};
const initialHistoryState: HistoryState<typeof initialState> = {
past: [
{ ...initialState, objects: simpleObjectsGenerator(2) },
{ ...initialState, objects: simpleObjectsGenerator(1) },
],
present: initialState,
future: [{ ...initialState, objects: simpleObjectsGenerator(3) }],
};

describe('history reducer', () => {
it('returns the initial state', () => {
const state = historyReducer(undefined, { type: undefined as never });
const state = reducer(undefined, { type: undefined as never });

expect(state).toEqual({
past: [],
present: initialCanvasState,
future: [],
});
expect(state).toEqual({ past: [], present: initialState, future: [] });
});

it('handles history undo', () => {
const state = historyReducer(initialState, historyActions.undo());
const state = reducer(initialHistoryState, historyActions.undo());

expect(state).toEqual({
past: [initialState.past[0]],
present: initialState.past[1],
future: [initialState.present, ...initialState.future],
past: [initialHistoryState.past[0]],
present: initialHistoryState.past[1],
future: [initialHistoryState.present, ...initialHistoryState.future],
});
});

it('handles history undo when there is no past', () => {
const initialStateWithNoPast = { ...initialState, past: [] };
const initialStateWithNoPast = { ...initialHistoryState, past: [] };

const state = historyReducer(initialStateWithNoPast, historyActions.undo());
const state = reducer(initialStateWithNoPast, historyActions.undo());

expect(state).toEqual(initialStateWithNoPast);
});

it('handles history redo', () => {
const state = historyReducer(initialState, historyActions.redo());
const state = reducer(initialHistoryState, historyActions.redo());

expect(state).toEqual({
past: [...initialState.past, initialState.present],
present: initialState.future[0],
past: [...initialHistoryState.past, initialHistoryState.present],
present: initialHistoryState.future[0],
future: [],
});
});

it('handles history redo when there is no future', () => {
const initialStateWithNoFuture = { ...initialState, future: [] };
const initialStateWithNoFuture = { ...initialHistoryState, future: [] };

const state = historyReducer(
initialStateWithNoFuture,
historyActions.redo(),
);
const state = reducer(initialStateWithNoFuture, historyActions.redo());

expect(state).toEqual(initialStateWithNoFuture);
});

it('handles history undo and redo in sequence', () => {
const undoedState = historyReducer(initialState, historyActions.undo());
const twiceUndoedState = historyReducer(undoedState, historyActions.undo());
const undoedState = reducer(initialHistoryState, historyActions.undo());
const twiceUndoedState = reducer(undoedState, historyActions.undo());

expect(twiceUndoedState).toEqual({
past: [],
present: undoedState.past[0],
future: [undoedState.present, ...undoedState.future],
});

const redoedState = historyReducer(twiceUndoedState, historyActions.redo());
const twiceRedoedState = historyReducer(redoedState, historyActions.redo());
const redoedState = reducer(twiceUndoedState, historyActions.redo());
const twiceRedoedState = reducer(redoedState, historyActions.redo());

expect(twiceRedoedState).toEqual(initialState);
expect(twiceRedoedState).toEqual(initialHistoryState);
});

it('resets history', () => {
const state = historyReducer(initialState, historyActions.reset());
const state = reducer(initialHistoryState, historyActions.reset());

expect(state).toEqual({
past: [],
present: initialCanvasState,
present: initialState,
future: [],
});
});

it('ignores the provided action types', () => {
const state = reducer(
{ past: [], present: initialState, future: [] },
actionsToIgnore[0]('bar'),
);

expect(state.past).toEqual([]);
expect(state.present).toEqual({ ...initialState, type: 'bar' });
expect(state.future).toEqual([]);
});

it('adds previous present state to past and sets result of provided reducer to present', () => {
const objects = simpleObjectsGenerator(2);
const state = reducer(initialHistoryState, addObjects(objects));

expect(state.past).toEqual([
...initialHistoryState.past,
initialHistoryState.present,
]);
expect(state.present).toEqual({ ...state.present, objects });
expect(state.future).toEqual([]);
});
});
64 changes: 22 additions & 42 deletions apps/client/src/stores/reducers/history.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { type Action, createAction, type Reducer } from '@reduxjs/toolkit';
import { initialState as initialCanvasState } from '../../services/canvas/slice';
import type { CanvasActionType, CanvasSliceState } from '../../services/canvas/slice';

export type CanvasHistoryState = {
past: CanvasSliceState[];
present: CanvasSliceState;
future: CanvasSliceState[];
};
import { createAction, isAnyOf } from '@reduxjs/toolkit';
import type { AnyAction, Reducer } from '@reduxjs/toolkit';

export type HistoryActionType =
(typeof historyActions)[keyof typeof historyActions]['type'];
export type HistoryState<T> = {
past: T[];
present: T;
future: T[];
};

export type HistoryAction = (typeof historyActions)[HistoryActionKey];
export type HistoryActionType = HistoryAction['type'];
export type HistoryActionKey = keyof typeof historyActions;

export const historyActions = {
Expand All @@ -19,43 +17,25 @@ export const historyActions = {
reset: createAction('history/reset'),
};

export type IgnoreActionType = HistoryActionType | CanvasActionType;

const IGNORED_ACTIONS: IgnoreActionType[] = [
'canvas/setToolType',
'canvas/setStageConfig',
'canvas/set',
'canvas/setSelectedNodeIds',
'canvas/copyNodes',
];

function isIgnoredActionType(type: string) {
return IGNORED_ACTIONS.includes(type as IgnoreActionType);
}

function historyReducer(
reducer: Reducer<
CanvasSliceState,
Action<HistoryActionType | CanvasActionType | undefined>
>,
) {
const initialState: CanvasHistoryState = {
function historyReducer<R extends Reducer, S extends ReturnType<R>>(
reducer: R,
initialState: S,
ignoredActions?: readonly AnyAction[],
): Reducer<HistoryState<S>> {
const initialHistoryState: HistoryState<S> = {
past: [],
present: reducer(initialCanvasState, { type: undefined }),
present: reducer(initialState, { type: undefined }),
future: [],
};

return function (
state = initialState,
action: Action<HistoryActionType | CanvasActionType>,
) {
const ignoredActionMatchers = ignoredActions?.map((action) => action.match);
const isAnyOfIgnoredActions = isAnyOf(...(ignoredActionMatchers ?? []));

return function (state = initialHistoryState, action) {
const { past, present, future } = state;

if (isIgnoredActionType(action.type)) {
return {
...state,
present: reducer(present, action),
};
if (ignoredActions && isAnyOfIgnoredActions(action as AnyAction)) {
return { past, present: reducer(present, action), future };
}

switch (action.type) {
Expand Down
13 changes: 11 additions & 2 deletions apps/client/src/stores/store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { configureStore } from '@reduxjs/toolkit';
import { listenerMiddleware } from './middlewares/listenerMiddleware';
import historyReducer from './reducers/history';
import canvas from '../services/canvas/slice';
import canvasReducer, {
ignoredActionsInHistory,
initialState,
} from '../services/canvas/slice';
import collaboration from '../services/collaboration/slice';
import library from '@/services/library/slice';

const canvas = historyReducer(
canvasReducer,
initialState,
ignoredActionsInHistory,
);

export const store = configureStore({
reducer: {
canvas: historyReducer(canvas),
canvas,
collaboration,
library,
},
Expand Down
7 changes: 7 additions & 0 deletions apps/client/src/test/data-generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,10 @@ export const makeCollabRoomURL = (roomId: string, baseUrl = window.origin) => {
export const nodeTypeGenerator = (): NodeType[] => {
return ['arrow', 'draw', 'ellipse', 'laser', 'rectangle', 'text'];
};

export const simpleObjectsGenerator = (length = 1) => {
return Array.from({ length }, () => ({
id: Date.now(),
name: `test${Date.now()}`,
}));
};
15 changes: 9 additions & 6 deletions apps/client/src/test/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import { render, screen, type RenderOptions } from '@testing-library/react';
import { Provider as StoreProvider } from 'react-redux';
import userEvent from '@testing-library/user-event';

Check warning on line 4 in apps/client/src/test/test-utils.tsx

View workflow job for this annotation

GitHub Actions / test (18.17)

Using exported name 'userEvent' as identifier for default export

Check warning on line 4 in apps/client/src/test/test-utils.tsx

View workflow job for this annotation

GitHub Actions / test (18.17)

Using exported name 'userEvent' as identifier for default export
import canvasReducer, {
ignoredActionsInHistory,
initialState as initialCanvasState,
} from '@/services/canvas/slice';
import historyReducer, {
type CanvasHistoryState,
} from '@/stores/reducers/history';
import historyReducer from '@/stores/reducers/history';
import collabReducer, {
initialState as initialCollabState,
} from '@/services/collaboration/slice';
Expand All @@ -28,20 +27,24 @@
store?: ReturnType<typeof setupStore>;
}

export const defaultPreloadedState = {
export const defaultPreloadedState: RootState = {
canvas: {
past: [],
present: initialCanvasState,
future: [],
} as CanvasHistoryState,
},
collaboration: initialCollabState,
library: initialLibraryState,
};

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
return configureStore({
reducer: {
canvas: historyReducer(canvasReducer),
canvas: historyReducer(
canvasReducer,
initialCanvasState,
ignoredActionsInHistory,
),
collaboration: collabReducer,
library: libraryReducer,
},
Expand Down
Loading