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

[#237] Allow filtering state properties before save it to history #238

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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?

Expand Down
22 changes: 13 additions & 9 deletions src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
239 changes: 239 additions & 0 deletions test/filterStateProps.spec.js
Original file line number Diff line number Diff line change
@@ -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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note from another user of redux-undo: In this case, it would make sense to just compose this as two reducers and then wrap only one of them with undoable.

Something like:

const baseReducer = combineReducers({
  position: positionReducer,
  count: undoable(countReducer),
})

I understand this is a very simplistic example, but I think it can be extrapolated to any scenario - you can simply put the data you want to be ignored into another slice of state and call it a day. What would be the benefit of filtering like this?


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)
})
})
})