diff --git a/apps/client/src/services/canvas/slice.ts b/apps/client/src/services/canvas/slice.ts index 7c75116..dad39d0 100644 --- a/apps/client/src/services/canvas/slice.ts +++ b/apps/client/src/services/canvas/slice.ts @@ -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 = ( + payload: T = undefined as T, + meta?: ActionMeta, +) => { + return { payload, meta }; +}; export const initialState: CanvasSliceState = { nodes: [], @@ -36,20 +48,6 @@ export const initialState: CanvasSliceState = { }, }; -export type ActionMeta = { - receivedFromWS?: boolean; - broadcast?: boolean; - duplicate?: boolean; - selectNodes?: boolean; -}; - -export const prepareMeta = ( - payload: T = undefined as T, - meta?: ActionMeta, -) => { - return { payload, meta }; -}; - export const canvasSlice = createSlice({ name: 'canvas', initialState, @@ -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; diff --git a/apps/client/src/stores/reducers/__tests__/history.test.ts b/apps/client/src/stores/reducers/__tests__/history.test.ts index 7c686c0..5d7d43c 100644 --- a/apps/client/src/stores/reducers/__tests__/history.test.ts +++ b/apps/client/src/stores/reducers/__tests__/history.test.ts @@ -1,73 +1,86 @@ -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; + type: 'foo' | 'bar'; +}; + +const initialState: State = { objects: [], type: 'foo' }; + +const addObjects = createAction('addObjects'); +const setType = createAction('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 = { + 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: [], @@ -75,19 +88,42 @@ describe('history reducer', () => { 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([]); + }); }); diff --git a/apps/client/src/stores/reducers/history.ts b/apps/client/src/stores/reducers/history.ts index b7a14f5..39e855b 100644 --- a/apps/client/src/stores/reducers/history.ts +++ b/apps/client/src/stores/reducers/history.ts @@ -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 = { + 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 = { @@ -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 - >, -) { - const initialState: CanvasHistoryState = { +function historyReducer>( + reducer: R, + initialState: S, + ignoredActions?: readonly AnyAction[], +): Reducer> { + const initialHistoryState: HistoryState = { past: [], - present: reducer(initialCanvasState, { type: undefined }), + present: reducer(initialState, { type: undefined }), future: [], }; - return function ( - state = initialState, - action: Action, - ) { + 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) { diff --git a/apps/client/src/stores/store.ts b/apps/client/src/stores/store.ts index 7b0e88a..78a27b7 100644 --- a/apps/client/src/stores/store.ts +++ b/apps/client/src/stores/store.ts @@ -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, }, diff --git a/apps/client/src/test/data-generators.ts b/apps/client/src/test/data-generators.ts index 7832559..5dbd722 100644 --- a/apps/client/src/test/data-generators.ts +++ b/apps/client/src/test/data-generators.ts @@ -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()}`, + })); +}; diff --git a/apps/client/src/test/test-utils.tsx b/apps/client/src/test/test-utils.tsx index 615e4bd..90fa657 100644 --- a/apps/client/src/test/test-utils.tsx +++ b/apps/client/src/test/test-utils.tsx @@ -3,11 +3,10 @@ import { render, screen, type RenderOptions } from '@testing-library/react'; import { Provider as StoreProvider } from 'react-redux'; import userEvent from '@testing-library/user-event'; 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'; @@ -28,12 +27,12 @@ interface ExtendedRenderOptions extends Omit { store?: ReturnType; } -export const defaultPreloadedState = { +export const defaultPreloadedState: RootState = { canvas: { past: [], present: initialCanvasState, future: [], - } as CanvasHistoryState, + }, collaboration: initialCollabState, library: initialLibraryState, }; @@ -41,7 +40,11 @@ export const defaultPreloadedState = { export const setupStore = (preloadedState?: PreloadedState) => { return configureStore({ reducer: { - canvas: historyReducer(canvasReducer), + canvas: historyReducer( + canvasReducer, + initialCanvasState, + ignoredActionsInHistory, + ), collaboration: collabReducer, library: libraryReducer, },