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

Field extensions #264

Closed
wants to merge 6 commits into from
Closed
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
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)
nmay231 marked this conversation as resolved.
Show resolved Hide resolved
if (!undoableConfig.disableWarnings) {
console.warn(
Copy link
Owner

Choose a reason for hiding this comment

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

maybe we should only show this warning if we detect that the state contains one of redux-undo's field names, or an array/primitive?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I thought about that. The issue is you would be checking the returned object for every action that was dispatched to the wrapped reducer (there is no way to check only the first time and then trust it from then on).

I thought it would be best to warn the user exactly once at initialization, no matter what the user has in their state. It should also be very clear in the documentation that this is the case (I can make it more explicit).

Thoughts?

Copy link
Owner

Choose a reason for hiding this comment

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

yeah, I see, that makes sense. then let's keep it as is but put the disableWarnings option as an argument to the function. let's also separate the field extensions into a separate package.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good 👌

'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',
Copy link
Owner

Choose a reason for hiding this comment

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

also here, should we only warn when we detect that it is overriding something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same idea as above.

'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) => {
Copy link
Owner

Choose a reason for hiding this comment

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

document these in README as well?

do you think the field extensions should be part of redux-undo core, or maybe a separate library? I don't think everyone will need it and it slightly increases the bundle size

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I had briefly mentioned in the PR description that these were WIP and would come out soon, but I realize that it is silly to leave them there. I'll remove them until they are fully implemented. 👍

As for separating them into a different package, that sounds like a good idea! They are purposely optional and, yes, not everyone needs it in their bundle (though tree-shaking is starting to mitigate the problem).

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'
Loading