Skip to content

Commit

Permalink
Merge 63f2551 into 931b2ec
Browse files Browse the repository at this point in the history
  • Loading branch information
nmay231 committed Mar 10, 2020
2 parents 931b2ec + 63f2551 commit 9ca8322
Show file tree
Hide file tree
Showing 11 changed files with 738 additions and 37 deletions.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "standard",
"extends": ["standard", "plugin:chai-friendly/recommended"],
"parser": "babel-eslint",
"env": {
"browser": true,
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ undoable(reducer, {

neverSkipReducer: false, // prevent undoable from skipping the reducer on undo/redo and clearHistoryType actions
syncFilter: false // set to `true` to synchronize the `_latestUnfiltered` state with `present` when an excluded action is dispatched

extension: () => (state) => state, // Add extensions like `actionField` and `flattenState`.
disableWarnings: false, // set to `true` to disable warnings from using extensions
})
```

Expand Down Expand Up @@ -395,6 +398,39 @@ ignoreActions(
)
```

### Extensions

There are a few extensions you can add to your undoable state that include extra fields and/or extra functionality. Similar to filter, you can use the `combineExtensions` helper to use mutiple extensions at once.

**`actionField`** adds the previous action that changed state as a field. There are multiple insert methods:

- `"actionType"` - Simply added the previous `.actionType` as a field alongside `.past`, `.present`, etc. Will include any redux-undo actions.
- `"action"` - Adds the entire action including the payload: `myState.action === { type: 'LAST_ACTION', ...actionPayload }`.
- `"inline"` - Inserts the action into the present state alongside the user fields. This allows you to get the action that produced the new state or any state in your history `state.present.action`, `state.past[0].action`. This action will *not* be overridden by redux-undo actions

**`flattenState`** copies the current state down a level allowing you to add history to your state without changing (much) of your component logic. For example, say you have the state:

```javascript
myState: {
present: 'White elephant gift',
quantity: 1,
given: '2020-12-25'
}
```

When you wrap the reducer with undoable, `myState` changes according to the [History API](#history-api). Using the `flattenState` extension still uses the history api, but also copies the present state back down a level. This allows redux-undo to have undoable history, while you do not have to change your component logic.

```javascript
myState: {
quantity: 1,
given: '2020-12-25',
past: [ ... ],
present: { quantity: 1, ... },
future: [ ... ]
}
```

Note: if a field conflicts with one of redux-undo's (like `.present` in the example) it will not be copied and you will have to access it with `myState.present.present === 'White elephant gift'`

## What is this magic? How does it work?

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@
"babel-loader": "^8.0.6",
"babel-plugin-istanbul": "^5.2.0",
"chai": "^4.2.0",
"chai-spies": "^1.0.0",
"coveralls": "^3.0.9",
"cross-env": "^6.0.3",
"eslint": "^6.7.2",
"eslint-config-standard": "^14.1.0",
"eslint-plugin-chai-friendly": "^0.5.0",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-node": "^10.0.0",
"eslint-plugin-promise": "^4.2.1",
Expand Down
200 changes: 200 additions & 0 deletions src/fieldExtensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* The flattenState() field extension allows the user to access fields normally like
* `state.field` instead of `state.present.field`.
*
* Warning: if your state has fields that conflict with redux-undo's like .past or .index
* they will be overridden. You must access them as `state.present.past` or `state.present.index`
*/

export const flattenState = () => {
return (undoableConfig) => {
console.log(undoableConfig)
if (!undoableConfig.disableWarnings) {
console.warn(
'Warning: the flattenState() extension prioritizes redux-undo fields when flattening state.',
'If your state has the fields `limit` and `present`, you must access them',
'with `state.present.limit` and `state.present.present` respectively.\n',
'Only works with objects as state. Do not use flattenState() with primitives or arrays.\n',
'Disable this warning by passing `disableWarnings: true` into the undoable config'
)
}

return (state) => {
// state.present MUST be spread first so that redux-undo fields have priority
return { ...state.present, ...state }
}
}
}

/**
* @callback actionFieldIncludeAction
* @param {Action} action - The current action.
* @returns {boolean}
*/

/**
* The actionField() field extension allows users to insert the last occuring action
* into their state.
*
* @param {Object} config - Configure actionField()
*
* @param {string} config.insertMethod - How the last action will be inserted. Possible options are:
* - actionType: { ...state, actionType: 'LAST_ACTION' }
* - action: { ...state, action: { type: 'LAST_ACTION', ...actionPayload } }
* - inline: { ...state, present: { action: { type: 'LAST', ...payload }, ...otherFields } }
*
* @param {actionFieldIncludeAction} config.includeAction - A filter function that decides if
* the action is inserted into history.
*/
export const actionField = ({ insertMethod, includeAction } = {}) => {
if (insertMethod === 'inline') {
return inlineActionField({ includeAction })
}

let extend
if (insertMethod === 'action') {
extend = (state, action) => ({ ...state, action })
} else if (!insertMethod || insertMethod === 'actionType') {
extend = (state, action) => ({ ...state, actionType: action.type })
} else {
throw new Error(
`Unrecognized \`insertMethod\` option for actionField() extension: ${insertMethod}.\n` +
'Options are "action", "inline", or "actionType"'
)
}

return (undoableConfig) => {
const included = includeAction || (() => true)

if (!undoableConfig.disableWarnings) {
console.warn(
'Warning: the actionField() extension might override other state fields',
'such as "action", "present.action", or "actionType".\n',
'Disable this warning by passing `disableWarnings: true` into the undoable config'
)
}

let lastAction = {}

return (state, action) => {
if (included(action)) {
lastAction = action
return extend(state, action)
}

return extend(state, lastAction)
}
}
}

/**
* @private
*/
const inlineActionField = ({ includeAction } = {}) => {
return (undoableConfig) => {
if (!undoableConfig.disableWarnings) {
console.warn(
'Warning: the actionField() extension might override other state fields',
'such as "action", "present.action", or "actionType".\n',
'Disable this warning by passing `disableWarnings: true` into the undoable config'
)
}

const ignoredActions = [
undoableConfig.undoType,
undoableConfig.redoType,
undoableConfig.jumpType,
undoableConfig.jumpToPastType,
undoableConfig.jumpToFutureType,
...undoableConfig.clearHistoryType,
...undoableConfig.initTypes
]

const included = includeAction || (() => true)

return (state, action) => {
if (included(action) && ignoredActions.indexOf(action.type) === -1) {
const newState = { ...state, present: { ...state.present, action } }

if (state.present === state._latestUnfiltered) {
newState._latestUnfiltered = newState.present
}
return newState
}

return state
}
}
}

// istanbul ignore next: This will be put on hold for now...
// eslint-disable-next-line no-unused-vars
const nullifyFields = (fields = [], nullValue = null) => {
const removeFields = (state) => {
if (!state) return state

for (const toNullify of fields) {
state[toNullify] = nullValue
}
}

return (undoableConfig) => {
const { redoType } = undoableConfig

return (state, action) => {
const newState = { ...state }

if (action.type === redoType) {
removeFields(newState.future[0])
} else {
removeFields(state.past[state.length - 1])
}

return newState
}
}
}

// istanbul ignore next: This will be put on hold for now...
// eslint-disable-next-line no-unused-vars
const sideEffects = (onUndo = {}, onRedo = {}) => {
return (undoableConfig) => {
const { undoType, redoType } = undoableConfig

const watchedTypes = Object.keys({ ...onUndo, ...onRedo })

// sideEffects() must have its own latestUnfiltered because when syncFilter = true
let lastPresent = {}

return (state, action) => {
const newState = { ...state }
if (lastPresent !== newState.present) {
let actions = [...newState.present.actions]

if (watchedTypes.indexOf(action.type) > -1) {
if (newState._latestUnfiltered !== newState.present) {
actions = [action]
} else {
actions.push(action)
}
}

lastPresent = newState.present = { ...newState.present, actions }
}

if (action.type === undoType) {
const oldActions = [...newState.future[0].actions].reverse()
for (const undone of oldActions) {
onUndo[undone.type](newState, undone)
}
} else if (action.type === redoType) {
const oldActions = newState.past[newState.past.length - 1].actions
for (const redone of oldActions) {
onUndo[redone.type](newState, redone)
}
}

return newState
}
}
}
13 changes: 13 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ export function combineFilters (...filters) {
, () => true)
}

// combineExtensions helper: include multiple field extensions at once
export function combineExtensions (...extensions) {
return (config) => {
const instantiated = extensions.map((ext) => ext(config))
return (state, action) => {
for (const extension of instantiated) {
state = extension(state, action)
}
return state
}
}
}

export function groupByActionTypes (rawActions) {
const actions = parseActions(rawActions)
return (action) => actions.indexOf(action.type) >= 0 ? action.type : null
Expand Down
16 changes: 13 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
export { ActionTypes, ActionCreators } from './actions'
export {
parseActions, isHistory,
includeAction, excludeAction,
combineFilters, groupByActionTypes, newHistory
parseActions,
groupByActionTypes,
includeAction,
excludeAction,
combineFilters,
combineExtensions,
isHistory,
newHistory
} from './helpers'

export {
actionField,
flattenState
} from './fieldExtensions'

export { default } from './reducer'

0 comments on commit 9ca8322

Please sign in to comment.