diff --git a/README.md b/README.md index b9351fd..9a468d9 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ are default values): undoable(reducer, { limit: false, // set to a number to turn on a limit for the history + filterStateProps: (_) => (_), // see `Filtering State Properties` + filter: () => true, // see `Filtering Actions` groupBy: () => null, // see `Grouping Actions` @@ -393,6 +395,52 @@ ignoreActions( ) ``` +### Filtering State Properties + +There exist [use cases](https://github.com/omnidan/redux-undo/issues/237) where you need to customize the `present` state before +save it to history. In those cases you can use `filterStateProps`. For instance, +consider the following state + +```js +const initialState = { + // Used only in present state and do not want to save it to history. + insignificant: { + x: 0, + y: 0 + }, + + // ...other properties +} +``` + +To filter out the `insignificant` property from the history you can use +`filterStateProps`, which takes a function with present unsaved `state` + and returns the actual state to be saved in history. + +```js +/** + * Redux root reducer. + */ + +import { combineReducers } from 'redux' + +import undoable from 'redux-undoable' +import reducer from './app/some/reducer' + +export default () => + combineReducers({ + someReducer: undoable(reducer, { + filterStateProps: (currentState) => { + // Remove `insignificant` from state + delete currentState.insignificant + return currentState + }, + }), + }) + +``` + +Now `past` states will not include `insignificant` property. ## What is this magic? How does it work? diff --git a/src/reducer.js b/src/reducer.js index cf488fb..79e5d74 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -19,21 +19,23 @@ function lengthWithoutFuture (history) { return history.past.length + 1 } -// insert: insert `state` into history, which means adding the current state -// into `past`, setting the new `state` as `present` and erasing -// the `future`. -function insert (history, state, limit, group) { - debug.log('inserting', state) - debug.log('new free: ', limit - lengthWithoutFuture(history)) - +// insert: filter `state` before insert it into history and then add +// the current state into `past`, setting the new `state` +// as `present` and erasing the `future`. +function insert (history, state, limit, group, filterStateProps) { const { past, _latestUnfiltered } = history const historyOverflow = limit && lengthWithoutFuture(history) >= limit + const _latestFiltered = filterStateProps(_latestUnfiltered) + + debug.log('inserting', _latestFiltered) + debug.log('new free: ', limit - lengthWithoutFuture(history)) + const pastSliced = past.slice(historyOverflow ? 1 : 0) const newPast = _latestUnfiltered != null ? [ ...pastSliced, - _latestUnfiltered + _latestFiltered ] : pastSliced return newHistory(newPast, state, [], group) @@ -84,6 +86,8 @@ export default function undoable (reducer, rawConfig = {}) { const config = { initTypes: parseActions(rawConfig.initTypes, ['@@redux-undo/INIT']), limit: rawConfig.limit, + // if `filterStateProps` has not set as function return a tautology function. + filterStateProps: typeof rawConfig.filterStateProps !== 'function' ? (_) => (_) : rawConfig.filterStateProps, filter: rawConfig.filter || (() => true), groupBy: rawConfig.groupBy || (() => null), undoType: rawConfig.undoType || ActionTypes.UNDO, @@ -244,7 +248,7 @@ export default function undoable (reducer, rawConfig = {}) { } // If the action wasn't filtered or grouped, insert normally - history = insert(history, res, config.limit, group) + history = insert(history, res, config.limit, group, config.filterStateProps) debug.log('inserted new state into history') debug.end(history) diff --git a/test/filterStateProps.spec.js b/test/filterStateProps.spec.js new file mode 100644 index 0000000..9b41704 --- /dev/null +++ b/test/filterStateProps.spec.js @@ -0,0 +1,239 @@ +import { expect } from 'chai' +import { createStore } from 'redux' +import undoable, { ActionTypes } from '../src/index' + +describe('Undoable with filterStateProps', () => { + let initialStoreState = { + position: { // will be excluded from history + x: 0, + y: 0 + }, + counter: 0 + } + + const countReducer = (state = initialStoreState, action = {}) => { + switch (action.type) { + case 'UPDATE_COUNTER': + return { + ...state, + counter: action.payload + } + case 'UPDATE_POSITION': + return { + ...state, + position: action.payload + } + default: + return state + } + } + + describe('save without filterStateProps', () => { + it('check initial state', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + let mockInitialState = mockUndoableReducer(undefined, {}) + + expect(store.getState()).to.deep.equal(mockInitialState, 'mockInitialState should be the same as our store\'s state') + }) + + it('update counter and check result', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + + let expectedResult = { ...initialStoreState, counter: 10 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('update position and check result', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + + let expectedResult = { ...initialStoreState, position: { x: 5, y: 5 } } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('UNDO counter update', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + store.dispatch({ type: 'UPDATE_COUNTER', payload: 15 }) + store.dispatch({ type: ActionTypes.UNDO }) + + let expectedResult = { ...initialStoreState, counter: 10 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('REDO counter update', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + store.dispatch({ type: 'UPDATE_COUNTER', payload: 15 }) + store.dispatch({ type: ActionTypes.UNDO }) + store.dispatch({ type: ActionTypes.REDO }) + + let expectedResult = { ...initialStoreState, counter: 15 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('UNDO position update', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 10, y: 10 } }) + store.dispatch({ type: ActionTypes.UNDO }) + + let expectedResult = { ...initialStoreState, position: { x: 5, y: 5 } } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('REDO position update', () => { + let mockUndoableReducer = undoable(countReducer) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 10, y: 10 } }) + store.dispatch({ type: ActionTypes.UNDO }) + store.dispatch({ type: ActionTypes.REDO }) + + let expectedResult = { ...initialStoreState, position: { x: 10, y: 10 } } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + }) + + describe('save with filterStateProps', () => { + it('check initial state', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + let mockInitialState = mockUndoableReducer(undefined, {}) + + expect(store.getState()).to.deep.equal(mockInitialState, 'mockInitialState should be the same as our store\'s state') + }) + + it('update counter and check result', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + + let expectedResult = { ...initialStoreState, counter: 10 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('update position and check result', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + + let expectedResult = { ...initialStoreState, position: { x: 5, y: 5 } } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('UNDO counter update', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + store.dispatch({ type: 'UPDATE_COUNTER', payload: 15 }) + store.dispatch({ type: ActionTypes.UNDO }) + + let expectedResult = { ...initialStoreState, counter: 10 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('REDO counter update', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_COUNTER', payload: 10 }) + store.dispatch({ type: 'UPDATE_COUNTER', payload: 15 }) + store.dispatch({ type: ActionTypes.UNDO }) + store.dispatch({ type: ActionTypes.REDO }) + + let expectedResult = { ...initialStoreState, counter: 15 } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('UNDO position update', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 10, y: 10 } }) + store.dispatch({ type: ActionTypes.UNDO }) + + let expectedResult = initialStoreState + expect(store.getState().present).to.deep.equal(expectedResult) + }) + + it('REDO position update', () => { + let mockUndoableReducer = undoable(countReducer, { + filterStateProps (state) { + return { + ...state, + position: initialStoreState.position + } + } + }) + let store = createStore(mockUndoableReducer, initialStoreState) + + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 5, y: 5 } }) + store.dispatch({ type: 'UPDATE_POSITION', payload: { x: 10, y: 10 } }) + store.dispatch({ type: ActionTypes.UNDO }) + store.dispatch({ type: ActionTypes.REDO }) + + let expectedResult = { ...initialStoreState, position: { x: 10, y: 10 } } + expect(store.getState().present).to.deep.equal(expectedResult) + }) + }) +})