Skip to content

Commit

Permalink
🌟 Add the ability to provide a custom merger
Browse files Browse the repository at this point in the history
  • Loading branch information
greena13 committed Oct 8, 2020
1 parent 6730199 commit 5f066ff
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 20 deletions.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down
21 changes: 19 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,15 @@ export interface UpdateItemActionCreatorOptions<T> extends RemoteActionCreatorOp
previousValues?: T,
}

export interface CreateItemActionCreatorOptions<T> extends RemoteActionCreatorOptionsWithMetadata<T> {
/**
* 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<T> { (existingItems: Array<ResourcesItem<T>>, newItem: ResourcesItem<T>): string[] }

export type MergerAndListParameterTuple<T> = [Array<ItemOrListParameters>, ListPositionsMerger<T>];

export interface ListOperations {
/**
* A An array of list keys to push the new item to the end of.
*/
Expand All @@ -822,11 +830,20 @@ export interface CreateItemActionCreatorOptions<T> extends RemoteActionCreatorOp
unshift?: Array<ItemOrListParameters>,

/**
* 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<ItemOrListParameters>,

/**
* 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<MergerAndListParameterTuple<T>>,
}

export interface CreateItemActionCreatorOptions<T> extends RemoteActionCreatorOptionsWithMetadata<T>, ListOperations {
}

export interface DestroyItemActionCreatorOptions<T> extends RemoteActionCreatorOptions<T> {
Expand Down
7 changes: 6 additions & 1 deletion src/action-creators/helpers/extractListOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
})
};
}

Expand Down
11 changes: 8 additions & 3 deletions src/actions/RESTful/createItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<string[], function>} [merge=[]] An array of tuples where the first element is an array of list keys
* and the second is a merger function that accepts
*/

/**
Expand Down Expand Up @@ -354,6 +357,8 @@ function reducer(resources, action) {
}
};

const newLists = applyListOperators(resources, listOperations, temporaryKey);

return {
...resources,
items: newItems,
Expand All @@ -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
};

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

/**
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/actions/RESTful/newItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/public-helpers/hasDefinedStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
59 changes: 49 additions & 10 deletions src/reducers/helpers/applyListOperators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
});
}
Expand Down Expand Up @@ -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
};
}
Expand Down
10 changes: 10 additions & 0 deletions src/reducers/helpers/keysExplicitlyReferencedByListOperations.js
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions src/reducers/helpers/listKeysForItemKeySubstitution.js
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 5f066ff

Please sign in to comment.