diff --git a/README.md b/README.md index aa05f27..3ae3f75 100644 --- a/README.md +++ b/README.md @@ -1379,6 +1379,7 @@ Often when you create a new item, you want it to appear in a list immediately (w | `push` | Array | [ ] | An array of list keys to push the new item to the end of. | | `unshift` | Array | [ ] | An array of list keys to add the new item to the beginning of. | | `invalidate` | Array | [ ] | An array of list keys for which to clear (invalidate). This is useful for when you know the item that was just created is likely to appear in a list, but you don't know where so you need to re-retrieve the whole list from the server. | +| `merge` | Array | [ ] | An array of tuples, where the first element is an array of list keys to run the custom merger on (the second element). See below for details | If you want to add the new item to the default (unspecified) list, you can use the `UNSPECIFIED_KEY` exported by the package: @@ -1396,7 +1397,31 @@ For example the following will unshift an item to the `newest` list and invalida ```javascript createTodoItem({ title: 'Pick up milk'}, { unshift: ['newest'], invalidate: ['*'] }); -``` +``` + +If the `push`, `unshift` or `invalidate` list operations do not do what you need, you can use the `merge` option to provide a custom function of your own. + +The merger function accepts two arguments: + +* An array of items in their current order +* The new item to merge into its correct position + +The merger function and the collection of list keys that is should operate on are specified as tuples (to allow specifying multiple custom mergers in the same action). + +The merger function must return an array of item keys in the correct order for the corresponding items (rather than an array of the the full item objects passed as arguments). + +For example, the following will sort the list of items with the key `'important'` by priority: + +```javascript +const sortListItemsByImportance = (items, newItem) => { + return itemsSortedByPriority([...items, newItem]).map(({ values: { id }}) => id); +} + +createTodoItem( + { title: 'Pick up milk'}, + { merge: [['important'], sortListItemsByImportance ]} +); +``` When the item is successfully created, the default createItem reducer expects the server to respond with a JSON object containing the item's attributes. If the request fails, it expects the server to respond with a JSON object containing an error. diff --git a/index.d.ts b/index.d.ts index b14d84d..4d90d51 100644 --- a/index.d.ts +++ b/index.d.ts @@ -810,7 +810,15 @@ export interface UpdateItemActionCreatorOptions extends RemoteActionCreatorOp previousValues?: T, } -export interface CreateItemActionCreatorOptions extends RemoteActionCreatorOptionsWithMetadata { +/** + * Function responsible for merging a new item into a list of existing ones by returning the list new item + * keys in the correct order + */ +export interface ListPositionsMerger { (existingItems: Array>, newItem: ResourcesItem): string[] } + +export type MergerAndListParameterTuple = [Array, ListPositionsMerger]; + +export interface ListOperations { /** * A An array of list keys to push the new item to the end of. */ @@ -822,11 +830,20 @@ export interface CreateItemActionCreatorOptions extends RemoteActionCreatorOp unshift?: Array, /** - * A An array of list keys for which to clear (invalidate). This is useful for when you know the item + * An array of list keys for which to clear (invalidate). This is useful for when you know the item * that was just created is likely to appear in a list, but you don't know where, so you need to * re-retrieve the whole list from the server. */ invalidate?: Array, + + /** + * An array of tuples where the first element is an array of list keys and the second is a merger + * function that accepts + */ + merge?: Array>, +} + +export interface CreateItemActionCreatorOptions extends RemoteActionCreatorOptionsWithMetadata, ListOperations { } export interface DestroyItemActionCreatorOptions extends RemoteActionCreatorOptions { diff --git a/src/action-creators/helpers/extractListOperations.js b/src/action-creators/helpers/extractListOperations.js index 5d8f139..d825e65 100644 --- a/src/action-creators/helpers/extractListOperations.js +++ b/src/action-creators/helpers/extractListOperations.js @@ -6,12 +6,17 @@ function getListKeys(listKeys, urlOnlyParams) { } function extractListOperations(actionCreatorOptions, urlOnlyParams) { - const { push, unshift, invalidate } = actionCreatorOptions; + const { push, unshift, invalidate, merge = [] } = actionCreatorOptions; return { push: getListKeys(push, urlOnlyParams), unshift: getListKeys(unshift, urlOnlyParams), invalidate: getListKeys(invalidate, urlOnlyParams), + merge: merge.map((mergerKeyPair) => { + const [keys, merger] = mergerKeyPair; + + return [getListKeys(keys), merger]; + }) }; } diff --git a/src/actions/RESTful/createItem.js b/src/actions/RESTful/createItem.js index 8540815..988b350 100644 --- a/src/actions/RESTful/createItem.js +++ b/src/actions/RESTful/createItem.js @@ -21,6 +21,7 @@ import isNew from '../../public-helpers/isNew'; import adaptOptionsForSingularResource from '../../action-creators/helpers/adaptOptionsForSingularResource'; import arrayFrom from '../../utils/array/arrayFrom'; import toPlural from '../../utils/string/toPlural'; +import listKeysForItemKeySubstitution from '../../reducers/helpers/listKeysForItemKeySubstitution'; const HTTP_REQUEST_TYPE = 'POST'; @@ -37,6 +38,8 @@ const HTTP_REQUEST_TYPE = 'POST'; * @property {string[]} [invalidate=[]] An array of list keys for which to clear (invalidate). This is useful * for when you know the item that was just created is likely to appear in a list, but you don't know * where, so you need to re-retrieve the whole list from the server. + * @property {Array} [merge=[]] An array of tuples where the first element is an array of list keys + * and the second is a merger function that accepts */ /** @@ -354,6 +357,8 @@ function reducer(resources, action) { } }; + const newLists = applyListOperators(resources, listOperations, temporaryKey); + return { ...resources, items: newItems, @@ -362,7 +367,7 @@ function reducer(resources, action) { * We add the new item (using its temporary id) to any lists that already exist in the store, * that the new item should be a part of - according to the listOperations specified. */ - lists: applyListOperators(resources.lists, listOperations, temporaryKey), + lists: newLists, newItemKey: temporaryKey }; @@ -414,7 +419,7 @@ function reducer(resources, action) { * For the local action creator, the CREATING state is skipped and the temporary keys have not been * added to the collection yet, so we're adding them for the first time */ - return applyListOperators(resources.lists, listOperations, key); + return applyListOperators(resources, listOperations, key); } else { /** @@ -424,7 +429,7 @@ function reducer(resources, action) { return { ...resources.lists, - ...([].concat(...Object.values(listOperations))).reduce((memo, id) => { + ...(listKeysForItemKeySubstitution(listOperations, resources.lists)).reduce((memo, id) => { const list = resources.lists[id] || LIST; const { positions } = list; diff --git a/src/actions/RESTful/newItem.js b/src/actions/RESTful/newItem.js index 1cd239c..0ee705b 100644 --- a/src/actions/RESTful/newItem.js +++ b/src/actions/RESTful/newItem.js @@ -110,7 +110,7 @@ function reducer(resources, action) { return { ...resources, items: newItems, - lists: applyListOperators(resources.lists, listOperations, temporaryKey), + lists: applyListOperators(resources, listOperations, temporaryKey), newItemKey: temporaryKey }; } diff --git a/src/public-helpers/hasDefinedStatus.js b/src/public-helpers/hasDefinedStatus.js index 128aebc..ee970c6 100644 --- a/src/public-helpers/hasDefinedStatus.js +++ b/src/public-helpers/hasDefinedStatus.js @@ -3,8 +3,8 @@ * @param {ResourcesItem|ResourcesList} itemOrList The item or list to consider * @returns {boolean} True if the resource item or list has started. */ -function hasDefinedStatus({ status: { type } }) { - return type !== null; +function hasDefinedStatus(itemOrList) { + return itemOrList.status && itemOrList.status.type !== null; } export default hasDefinedStatus; diff --git a/src/reducers/helpers/applyListOperators.js b/src/reducers/helpers/applyListOperators.js index 5dc0b51..4227027 100644 --- a/src/reducers/helpers/applyListOperators.js +++ b/src/reducers/helpers/applyListOperators.js @@ -2,20 +2,21 @@ import { LIST } from '../../constants/DataStructures'; import contains from '../../utils/list/contains'; import { getConfiguration } from '../../configuration'; import without from '../../utils/list/without'; +import getList from '../../utils/getList'; +import getItem from '../../utils/getItem'; +import keysExplicitlyReferencedByListOperations from './keysExplicitlyReferencedByListOperations'; +import assertInDevMode from '../../utils/assertInDevMode'; +import warn from '../../utils/dev/warn'; -function applyListOperators(lists, listOperations = {}, temporaryKey) { +function applyListOperators(resources, listOperations = {}, temporaryKey) { const updatedLists = {}; const { listWildcard } = getConfiguration(); - const keysExplicitlyReferenced = [ - ...(listOperations.push || []), - ...(listOperations.unshift || []), - ...(listOperations.invalidate || []), - ]; + const keysExplicitlyReferenced = keysExplicitlyReferencedByListOperations(listOperations); function pushPosition(listKey) { - const existingList = lists[listKey] || LIST; + const existingList = resources.lists[listKey] || LIST; if (contains(existingList.positions, temporaryKey)) { updatedLists[listKey] = existingList; @@ -31,7 +32,7 @@ function applyListOperators(lists, listOperations = {}, temporaryKey) { } function unshiftPosition(listKey) { - const existingList = lists[listKey] || LIST; + const existingList = resources.lists[listKey] || LIST; if (contains(existingList.positions, temporaryKey)) { updatedLists[listKey] = existingList; @@ -50,8 +51,29 @@ function applyListOperators(lists, listOperations = {}, temporaryKey) { updatedLists[listKey] = LIST; } + function applyCustomReducer(listKey, customMerger) { + const newItem = getItem(resources, temporaryKey); + const currentList = getList(resources, listKey).items; + + const positions = customMerger([...currentList], newItem); + + assertInDevMode(() => { + if (!Array.isArray(positions)) { + warn( + `Invalid value '${positions}' returned from custom merger function for list with key '${listKey}'. \ + Check the function you're passing to the merge option returns an array of position values.` + ); + } + }); + + updatedLists[listKey] = { + ...currentList, + positions + }; + } + function applyToAllListsNotExplicitlyReferenced(listOperation) { - without(Object.keys(lists), keysExplicitlyReferenced).forEach((listKey) => { + without(Object.keys(resources.lists), keysExplicitlyReferenced).forEach((listKey) => { listOperation(listKey); }); } @@ -80,8 +102,25 @@ function applyListOperators(lists, listOperations = {}, temporaryKey) { } }); + /** + * If a custom merger has been supplied, we apply it + */ + listOperations.merge.forEach((mergerKeyPair) => { + const [keys, merger] = mergerKeyPair; + + keys.forEach((listKey) => { + const applyReducer = (_listKey) => applyCustomReducer(_listKey, merger); + + if (listKey === listWildcard) { + applyToAllListsNotExplicitlyReferenced(applyReducer); + } else { + applyCustomReducer(listKey, merger); + } + }); + }); + return { - ...lists, + ...resources.lists, ...updatedLists }; } diff --git a/src/reducers/helpers/keysExplicitlyReferencedByListOperations.js b/src/reducers/helpers/keysExplicitlyReferencedByListOperations.js new file mode 100644 index 0000000..78cd158 --- /dev/null +++ b/src/reducers/helpers/keysExplicitlyReferencedByListOperations.js @@ -0,0 +1,10 @@ +function keysExplicitlyReferencedByListOperations({ push = [], unshift = [], invalidate = [], merge = [] }) { + return [ + ...push, + ...unshift, + ...invalidate, + ...merge.reduce((memo, mergerKeyPair) => memo.concat(mergerKeyPair[0]), []), + ]; +} + +export default keysExplicitlyReferencedByListOperations; diff --git a/src/reducers/helpers/listKeysForItemKeySubstitution.js b/src/reducers/helpers/listKeysForItemKeySubstitution.js new file mode 100644 index 0000000..d277424 --- /dev/null +++ b/src/reducers/helpers/listKeysForItemKeySubstitution.js @@ -0,0 +1,28 @@ +import keysExplicitlyReferencedByListOperations from './keysExplicitlyReferencedByListOperations'; +import { getConfiguration } from '../../configuration'; +import contains from '../../utils/list/contains'; + +/** + * Processes a hash of ListOperations and returns the keys of the lists that it matches + * @param {ListOperations} listOperations Hash of list operations to analyse + * @param {Object} lists Collection of existing lists + * @returns {string[]} Collection of list keys matching the list operations + */ +function listKeysForItemKeySubstitution(listOperations, lists) { + const keysExplicitlyReferenced = keysExplicitlyReferencedByListOperations(listOperations); + + if (contains(keysExplicitlyReferenced, getConfiguration().listWildcard)) { + + /** + * If one of the list operations contained a wildcard, then we need to search all the lists + */ + return Object.keys(lists); + } + + /** + * If no wildcard was used, we just return the keys of the lists that were explicitly referenced + */ + return keysExplicitlyReferenced; +} + +export default listKeysForItemKeySubstitution;