From 1e28d977b4dca9a5f9d0dda33c7f24ed49e95441 Mon Sep 17 00:00:00 2001 From: Charles Kornoelje <33156025+charkour@users.noreply.github.com> Date: Sun, 24 Oct 2021 17:50:16 -0400 Subject: [PATCH] add historyDepthLimit and some unit tests --- package.json | 1 + pnpm-lock.yaml | 40 +++++++++++++++ src/factory.ts | 46 +++++++++++------ src/middleware.ts | 102 ++++++++++++++++++++------------------ src/utils.ts | 2 +- stories/bears.stories.tsx | 38 ++++++++++---- test/app.test.tsx | 2 +- test/middleware.test.tsx | 47 ++++++++++++++++++ 8 files changed, 203 insertions(+), 75 deletions(-) create mode 100644 test/middleware.test.tsx diff --git a/package.json b/package.json index 3579f6d..2ea163c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@storybook/addon-links": "^6.3.8", "@storybook/addons": "^6.3.8", "@storybook/react": "^6.3.8", + "@testing-library/react-hooks": "7.0.2", "@types/lodash.isequal": "4.5.5", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f28e9ba..7271b82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ specifiers: '@storybook/addon-links': ^6.3.8 '@storybook/addons': ^6.3.8 '@storybook/react': ^6.3.8 + '@testing-library/react-hooks': 7.0.2 '@types/lodash.isequal': 4.5.5 '@types/react': ^17.0.20 '@types/react-dom': ^17.0.9 @@ -31,6 +32,7 @@ devDependencies: '@storybook/addon-links': 6.3.12_react-dom@17.0.2+react@17.0.2 '@storybook/addons': 6.3.12_react-dom@17.0.2+react@17.0.2 '@storybook/react': 6.3.12_755f3acb7fe5750b5738a46e04688b88 + '@testing-library/react-hooks': 7.0.2_react-dom@17.0.2+react@17.0.2 '@types/lodash.isequal': 4.5.5 '@types/react': 17.0.31 '@types/react-dom': 17.0.10 @@ -3473,6 +3475,28 @@ packages: - '@types/react' dev: true + /@testing-library/react-hooks/7.0.2_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + react-test-renderer: '>=16.9.0' + peerDependenciesMeta: + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.15.4 + '@types/react': 17.0.31 + '@types/react-dom': 17.0.10 + '@types/react-test-renderer': 17.0.1 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-error-boundary: 3.1.3_react@17.0.2 + dev: true + /@types/babel__core/7.1.16: resolution: {integrity: sha512-EAEHtisTMM+KaKwfWdC3oyllIqswlznXCIVCt7/oRNrh+DhgT4UEBNC/jlADNjvw7UnfbcdkGQcPVZ1xYiLcrQ==} dependencies: @@ -3704,6 +3728,12 @@ packages: '@types/react': 17.0.31 dev: true + /@types/react-test-renderer/17.0.1: + resolution: {integrity: sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==} + dependencies: + '@types/react': 17.0.31 + dev: true + /@types/react-textarea-autosize/4.3.6: resolution: {integrity: sha512-cTf8tCem0c8A7CERYbTuF+bRFaqYu7N7HLCa6ZhUhDx8XnUsTpGx5udMWljt87JpciUKuUkImKPEsy6kcKhrcQ==} dependencies: @@ -11032,6 +11062,16 @@ packages: react-is: 17.0.2 dev: true + /react-error-boundary/3.1.3_react@17.0.2: + resolution: {integrity: sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.15.4 + react: 17.0.2 + dev: true + /react-error-overlay/6.0.9: resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} dev: true diff --git a/src/factory.ts b/src/factory.ts index 1b97826..1981db7 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -1,4 +1,4 @@ -import createVanilla from 'zustand/vanilla'; +import createVanilla, { GetState } from 'zustand/vanilla'; import { Options } from './middleware'; import { filterState } from './utils'; @@ -27,27 +27,17 @@ export const createUndoStore = () => { futureStates: [], isUndoHistoryEnabled: true, undo: () => { - const { prevStates, futureStates, setStore, getStore, options } = get(); - if (prevStates.length > 0) { - futureStates.push(filterState(getStore(), options?.omit || [])); - const prevState = prevStates.pop(); - setStore(prevState); - } + handleStoreUpdates(get, 'undo'); }, redo: () => { - const { prevStates, futureStates, setStore, getStore, options } = get(); - if (futureStates.length > 0) { - prevStates.push(filterState(getStore(), options?.omit || [])); - const futureState = futureStates.pop(); - setStore(futureState); - } + handleStoreUpdates(get, 'redo'); }, clear: () => { set({ prevStates: [], futureStates: [] }); }, - setIsUndoHistoryEnabled: isEnabled => { + setIsUndoHistoryEnabled: (isEnabled) => { const { prevStates, getStore, options } = get(); - const currState = filterState(getStore(), options?.omit || []); + const currState = filterState(getStore(), options?.omit); set({ isUndoHistoryEnabled: isEnabled, @@ -60,3 +50,29 @@ export const createUndoStore = () => { }; }); }; + +const handleStoreUpdates = ( + get: GetState, + action: 'undo' | 'redo' +) => { + const { prevStates, futureStates, setStore, getStore, options } = get(); + + const isUndo = action === 'undo'; + const currentActionStates = isUndo ? prevStates : futureStates; + const otherActionStates = isUndo ? futureStates : prevStates; + const stateDepthLimit = isUndo + ? // default to historyLimit if no future limit. + options?.futureDepthLimit || options?.historyDepthLimit + : options?.historyDepthLimit; + + if (currentActionStates.length > 0) { + // check history limit + if (stateDepthLimit && otherActionStates.length >= stateDepthLimit) { + // pop front + otherActionStates.shift(); + } + otherActionStates.push(filterState(getStore(), options?.omit)); + const currentStoreState = currentActionStates.pop(); + setStore(currentStoreState); + } +}; diff --git a/src/middleware.ts b/src/middleware.ts index 57fa366..7b7a748 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -16,63 +16,69 @@ export interface Options { // TODO: improve this type. ignored should only be fields on TState omit?: string[]; allowUnchanged?: boolean; + historyDepthLimit?: number; + futureDepthLimit?: number; } // custom zustand middleware to get previous state -export const undoMiddleware = ( - config: StateCreator, - options?: Options -) => (set: SetState, get: GetState, api: StoreApi) => { - const undoStore = createUndoStore(); - const { getState, setState } = undoStore; - return config( - args => { - /* TODO: const, should call this function and inject the values once, but it does +export const undoMiddleware = + (config: StateCreator, options?: Options) => + (set: SetState, get: GetState, api: StoreApi) => { + const undoStore = createUndoStore(); + const { getState, setState } = undoStore; + return config( + (args) => { + /* TODO: const, should call this function and inject the values once, but it does it on every action call currently. */ - const { - undo, - clear, - redo, - setIsUndoHistoryEnabled, - isUndoHistoryEnabled, - } = getState(); - // inject helper functions to user defined store. - set({ - undo, - clear, - redo, - getState, - setIsUndoHistoryEnabled, - }); + const { + undo, + clear, + redo, + setIsUndoHistoryEnabled, + isUndoHistoryEnabled, + } = getState(); + // inject helper functions to user defined store. + set({ + undo, + clear, + redo, + getState, + setIsUndoHistoryEnabled, + }); - // Get the last state before updating state - const lastState = filterState({ ...get() }, options?.omit || []); + // Get the last state before updating state + const lastState = filterState({ ...get() }, options?.omit); - set(args); + set(args); - // Get the current state after updating state - const currState = filterState({ ...get() }, options?.omit || []); + // Get the current state after updating state + const currState = filterState({ ...get() }, options?.omit); - // Only store changes if state isn't equal (or option has been set) - const shouldStoreChange = - isUndoHistoryEnabled && - (!isEqual(lastState, currState) || options?.allowUnchanged); + // Only store changes if state isn't equal (or option has been set) + const shouldStoreChange = + isUndoHistoryEnabled && + (!isEqual(lastState, currState) || options?.allowUnchanged); - if (shouldStoreChange) { - const prevStates = getState().prevStates; + const limit = options?.historyDepthLimit; - setState({ - prevStates: [...prevStates, lastState], - setStore: set, - futureStates: [], - getStore: get, - options, - }); - } - }, - get, - api - ); -}; + if (shouldStoreChange) { + const prevStates = getState().prevStates; + if (limit && prevStates.length >= limit) { + // pop front + prevStates.shift(); + } + setState({ + prevStates: [...prevStates, lastState], + setStore: set, + futureStates: [], + getStore: get, + options, + }); + } + }, + get, + api + ); + }; export default undoMiddleware; diff --git a/src/utils.ts b/src/utils.ts index 1f69934..71cffb1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ // TODO: make this a better type -export const filterState = (state: any, ignored: string[]) => { +export const filterState = (state: any, ignored: string[] = []) => { const filteredState: any = {}; Object.keys(state).forEach((key: string) => { if (!ignored.includes(key)) { diff --git a/stories/bears.stories.tsx b/stories/bears.stories.tsx index a44a748..f5992f2 100644 --- a/stories/bears.stories.tsx +++ b/stories/bears.stories.tsx @@ -19,7 +19,7 @@ const meta: Meta = { export default meta; -interface StoreState extends UndoState { +export interface StoreState extends UndoState { bears: number; ignored: number; increasePopulation: () => void; @@ -29,19 +29,25 @@ interface StoreState extends UndoState { } // create a store with undo middleware -const useStore = create( +export const useStore = create( undoMiddleware( - set => ({ + (set) => ({ bears: 0, ignored: 0, increasePopulation: () => - set(state => ({ bears: state.bears + 1, ignored: state.ignored + 1 })), + set((state) => ({ + bears: state.bears + 1, + ignored: state.ignored + 1, + })), decreasePopulation: () => - set(state => ({ bears: state.bears - 1, ignored: state.ignored - 1 })), - doNothing: () => set(state => ({ ...state })), + set((state) => ({ + bears: state.bears - 1, + ignored: state.ignored - 1, + })), + doNothing: () => set((state) => ({ ...state })), removeAllBears: () => set({ bears: 0 }), }), - { omit: ['ignored'] } + { omit: ['ignored'], historyDepthLimit: 10 } ) ); @@ -84,15 +90,27 @@ const App = () => {

- - + +
); }; -const Template: Story<{}> = args => ; +const Template: Story<{}> = (args) => ; // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test // https://storybook.js.org/docs/react/workflows/unit-testing diff --git a/test/app.test.tsx b/test/app.test.tsx index 47f9169..519500e 100644 --- a/test/app.test.tsx +++ b/test/app.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import * as ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom'; import { Default as App } from '../stories/bears.stories'; describe('App', () => { diff --git a/test/middleware.test.tsx b/test/middleware.test.tsx new file mode 100644 index 0000000..7a3a132 --- /dev/null +++ b/test/middleware.test.tsx @@ -0,0 +1,47 @@ +import { useStore } from '../stories/bears.stories'; +import { renderHook, act } from '@testing-library/react-hooks'; + +describe('zundo store', () => { + const { result, rerender } = renderHook(() => useStore()); + + test('increment', () => { + for (let i = 0; i < 6; i++) { + expect(result.current.bears).toBe(i); + act(() => { + result.current.increasePopulation(); + }); + rerender(); + expect(result.current.bears).toBe(i + 1); + } + }); + + test('decrement', () => { + for (let i = 6; i > 0; i--) { + expect(result.current.bears).toBe(i); + act(() => { + result.current.decreasePopulation(); + }); + rerender(); + expect(result.current.bears).toBe(i - 1); + } + }); + + test('undo', () => { + rerender(); + expect(result.current.bears).toBe(0); + act(() => { + result.current.undo?.(); + }); + rerender(); + expect(result.current.bears).toBe(1); + }); + + test('redo', () => { + expect(result.current.bears).toBe(1); + act(() => { + result.current.redo?.(); + }); + rerender(); + expect(result.current.bears).toBe(0); + }); +});