Skip to content

Commit

Permalink
add historyDepthLimit and some unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
charkour committed Oct 24, 2021
1 parent 51896bf commit 1e28d97
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 75 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 31 additions & 15 deletions src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import createVanilla from 'zustand/vanilla';
import createVanilla, { GetState } from 'zustand/vanilla';
import { Options } from './middleware';
import { filterState } from './utils';

Expand Down Expand Up @@ -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,
Expand All @@ -60,3 +50,29 @@ export const createUndoStore = () => {
};
});
};

const handleStoreUpdates = (
get: GetState<UndoStoreState>,
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);
}
};
102 changes: 54 additions & 48 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <TState extends UndoState>(
config: StateCreator<TState>,
options?: Options
) => (set: SetState<TState>, get: GetState<TState>, api: StoreApi<TState>) => {
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 =
<TState extends UndoState>(config: StateCreator<TState>, options?: Options) =>
(set: SetState<TState>, get: GetState<TState>, api: StoreApi<TState>) => {
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;
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
38 changes: 28 additions & 10 deletions stories/bears.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,19 +29,25 @@ interface StoreState extends UndoState {
}

// create a store with undo middleware
const useStore = create<StoreState>(
export const useStore = create<StoreState>(
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 }
)
);

Expand Down Expand Up @@ -84,15 +90,27 @@ const App = () => {
<br />
<button onClick={clear}>clear</button>
<br />
<button onClick={() => { setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(false) }}>Disable History</button>
<button onClick={() => { setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(true) }}>Enable History</button>
<button
onClick={() => {
setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(false);
}}
>
Disable History
</button>
<button
onClick={() => {
setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(true);
}}
>
Enable History
</button>
<br />
<button onClick={doNothing}>do nothing</button>
</div>
);
};

const Template: Story<{}> = args => <App {...args} />;
const Template: Story<{}> = (args) => <App {...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
Expand Down
2 changes: 1 addition & 1 deletion test/app.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Loading

0 comments on commit 1e28d97

Please sign in to comment.