CRUD: populates `items` and `itemsCount` when a new item is created (#47632)

* minor change in reducer

* use organizeItemsById to compute new items

* add reducer test with pick optimisticUrlParameters

* rename constant name

* add optimisticUrlParameters to CRUD actions types

* use organizeItemsById to generate store IDs

* add more reducer tests

* compute nextItemsData based on ids

* tessstssssss

* add a order_by: name test

* fix sorting data when url parameters

* add tests

* introduce filterDataByKeys helper fn

* fix process to sort items optimistically

* rollback wrong change in actions

* changelog

* set action `options` as optional

* set default value for options
This commit is contained in:
Damián Suárez 2024-05-27 16:34:12 +01:00 committed by GitHub
parent 460d73eee0
commit f51b93359f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 607 additions and 66 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
CRUD: populates items and itemsCount when a new item is created

View File

@ -36,7 +36,7 @@ export function createItemSuccess(
key: IdType,
item: Item,
query: Partial< ItemQuery >,
options: CrudActionOptions
options?: CrudActionOptions
) {
return {
type: TYPES.CREATE_ITEM_SUCCESS as const,

View File

@ -8,7 +8,11 @@ import { Reducer } from 'redux';
*/
import { Actions } from './actions';
import CRUD_ACTIONS from './crud-actions';
import { getRequestIdentifier, organizeItemsById } from './utils';
import {
filterDataByKeys,
getRequestIdentifier,
organizeItemsById,
} from './utils';
import { getTotalCountResourceName } from '../utils';
import { TYPES } from './action-types';
import type { IdType, Item, ItemQuery } from './types';
@ -85,23 +89,41 @@ export const createReducer = (
};
case TYPES.CREATE_ITEM_SUCCESS: {
const { options = {} } = payload;
const { objItems, ids } = organizeItemsById(
[ payload.item ],
options.optimisticUrlParameters,
itemData
);
const data = {
...itemData,
...objItems,
};
const createItemSuccessRequestId = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
payload.key,
ids[ 0 ],
payload.query
);
const { options } = payload;
const data = {
...itemData,
[ payload.key ]: {
...( itemData[ payload.key ] || {} ),
...payload.item,
},
};
const getItemQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
let items = state.items;
let queryItems = Object.keys( data ).map( ( key ) => +key );
const getItemCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options?.optimisticQueryUpdate || {}
);
let currentItems = state.items;
const currentItemsByQueryId =
currentItems[ getItemQueryId ]?.data || [];
let nextItemsData = [ ...currentItemsByQueryId, ...ids ];
let itemsCount = state.itemsCount;
@ -126,49 +148,52 @@ export const createReducer = (
const order_by = options.optimisticQueryUpdate
?.order_by as OrderBy;
let sortingData = Object.values( data );
sortingData = sortingData.sort( ( a, b ) =>
( a[ order_by ] as string )
.toLowerCase()
.localeCompare(
(
b[ order_by ] as string
).toLowerCase()
)
/*
* Pick the data to sort by the order_by property,
* from the data store,
* based on the nextItemsData ids.
*/
let sourceDataToOrderBy = Object.values(
filterDataByKeys( data, nextItemsData )
) as Item[];
sourceDataToOrderBy = sourceDataToOrderBy.sort(
( a, b ) =>
String( a[ order_by ] as IdType )
.toLowerCase()
.localeCompare(
String(
b[ order_by ] as IdType
).toLowerCase()
)
);
queryItems = sortingData.map( ( item ) =>
Number( item.id )
// Pick the ids from the sorted data.
const { ids: sortedIds } = organizeItemsById(
sourceDataToOrderBy,
options.optimisticUrlParameters
);
// Update the items data with the sorted ids.
nextItemsData = sortedIds;
}
const getItemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const getItemCountQuery = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
items = {
...state.items,
[ getItemQuery ]: {
...state.items[ getItemQuery ],
data: queryItems,
currentItems = {
...currentItems,
[ getItemQueryId ]: {
data: nextItemsData,
},
};
itemsCount = {
...state.itemsCount,
[ getItemCountQuery ]: Object.keys( data ).length,
[ getItemCountQueryId ]: nextItemsData.length,
};
}
return {
...state,
items,
items: currentItems,
itemsCount,
data,
requesting: {

View File

@ -4,8 +4,8 @@
import { Actions } from '../actions';
import { createReducer, ResourceState } from '../reducer';
import { CRUD_ACTIONS } from '../crud-actions';
import { getResourceName } from '../../utils';
import { getRequestIdentifier } from '..//utils';
import { getResourceName, getTotalCountResourceName } from '../../utils';
import { getRequestIdentifier } from '../utils';
import { Item, ItemQuery } from '../types';
import TYPES from '../action-types';
@ -314,7 +314,7 @@ describe( 'crud reducer', () => {
} );
describe( 'should handle CREATE_ITEM_SUCCESS', () => {
it( 'when no options are passed', () => {
it( 'with empty previous state', () => {
const item: Item = {
id: 2,
name: 'Off the hook!',
@ -325,12 +325,6 @@ describe( 'crud reducer', () => {
status: 'draft',
};
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
const state = reducer( defaultState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
@ -339,15 +333,90 @@ describe( 'crud reducer', () => {
options: {},
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
expect( state.data[ 2 ].name ).toEqual( item.name );
expect( state.data[ 2 ].status ).toEqual( item.status );
expect( state.requesting[ resourceName ] ).toEqual( false );
// Not optimitic query update
expect( state.items ).toEqual( {} );
expect( state.itemsCount ).toEqual( {} );
} );
it( 'when optimisticQueryUpdate is defined', () => {
it( 'with previous state', () => {
const item: Item = {
id: 3,
name: 'banana',
status: 'draft',
};
const query = {
name: 'banana',
status: 'draft',
};
const queryId = { type: 'fruit' };
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
queryId
);
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
queryId
);
const initialState: ResourceState = {
items: {
[ getItemsQueryId ]: {
data: [ 1, 2 ],
},
},
itemsCount: {
[ getItemsCountQueryId ]: 2,
},
errors: {},
data: {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
},
requesting: {},
};
const state = reducer( initialState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options: {},
} );
expect( state.data ).toEqual( {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
3: { id: 3, name: 'banana', status: 'draft' },
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
// Not optimitic query update
expect( state.items[ getItemsQueryId ].data ).toHaveLength( 2 );
expect( state.items[ getItemsQueryId ].data ).toEqual( [ 1, 2 ] );
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 2 );
} );
it( 'with empty previous state, and optimisticQueryUpdate options', () => {
const item: Item = {
id: 7,
name: 'Off the hook!',
@ -370,8 +439,13 @@ describe( 'crud reducer', () => {
options,
} );
expect( state.data[ 7 ].name ).toEqual( item.name );
expect( state.data[ 7 ].status ).toEqual( item.status );
expect( state.data ).toEqual( {
7: {
id: 7,
name: 'Off the hook!',
status: 'draft',
},
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
@ -380,30 +454,447 @@ describe( 'crud reducer', () => {
);
expect( state.requesting[ resourceName ] ).toEqual( false );
const itemQuery = getRequestIdentifier(
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
expect( state.items[ itemQuery ].data ).toHaveLength( 1 );
expect( state.items[ itemQuery ].data[ 0 ] ).toEqual( 7 ); // Item id
expect( state.items[ getItemsQueryId ].data ).toHaveLength( 1 );
expect( state.items[ getItemsQueryId ].data[ 0 ] ).toEqual( 7 ); // Item id
const itemsKey = Object.keys( state.items );
expect( itemsKey ).toHaveLength( 1 );
expect( itemsKey[ 0 ] ).toEqual( itemQuery );
const itemsCountQuery = getRequestIdentifier(
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const itemsCountKey = Object.keys( state.itemsCount );
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 1 );
} );
// ItemsCount should be 1
expect( state.itemsCount[ itemsCountQuery ] ).toEqual( 1 );
it( 'with previous state, and optimisticQueryUpdate options', () => {
const item: Item = {
id: 7,
name: 'kiwi',
status: 'draft',
};
expect( itemsCountKey ).toHaveLength( 1 );
expect( itemsCountKey[ 0 ] ).toEqual( itemsCountQuery );
const query = {
name: 'kiwi',
status: 'draft',
};
const options = {
optimisticQueryUpdate: { random: 'fruit' },
};
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const initialState: ResourceState = {
items: {
[ getItemsQueryId ]: {
data: [ 1, 2 ],
},
},
itemsCount: {
[ getItemsCountQueryId ]: 2,
},
errors: {},
data: {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
},
requesting: {},
};
const state = reducer( initialState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options,
} );
expect( state.data ).toEqual( {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
7: { id: 7, name: 'kiwi', status: 'draft' },
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
expect( state.items[ getItemsQueryId ].data ).toHaveLength( 3 );
expect( state.items[ getItemsQueryId ].data ).toEqual( [
1, 2, 7,
] );
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 3 );
} );
it( `with previous state,
and optimisticQueryUpdate options with order_by: name`, () => {
const item: Item = {
id: 7,
name: 'kiwi',
status: 'draft',
};
const query = {
name: 'kiwi',
status: 'draft',
};
const options = {
optimisticQueryUpdate: { order_by: 'name' },
};
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const initialState: ResourceState = {
items: {
[ getItemsQueryId ]: {
data: [ 1, 2 ],
},
},
itemsCount: {
[ getItemsCountQueryId ]: 2,
},
errors: {},
data: {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
},
requesting: {},
};
const state = reducer( initialState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options,
} );
expect( state.data ).toEqual( {
1: { id: 1, name: 'apple', status: 'draft' },
2: { id: 2, name: 'pine', status: 'publish' },
7: { id: 7, name: 'kiwi', status: 'draft' },
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
expect( state.items[ getItemsQueryId ].data ).toEqual( [
// order by name: apple(1), kiwi(7), pine(2)
1, 7, 2,
] );
} );
it( `with empty previous state,
and optimisticQueryUpdate and
optimisticUrlParameters options`, () => {
const item: Item = {
id: 7,
name: 'Off the hook!',
status: 'draft',
};
const query = {
name: 'Off the hook!',
status: 'draft',
parent_id: 200,
};
const options = {
optimisticQueryUpdate: { parent_id: 200 },
optimisticUrlParameters: [ 200 ],
};
const state = reducer( defaultState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options,
} );
expect( state.data ).toEqual( {
'200/7': {
id: 7,
name: 'Off the hook!',
status: 'draft',
},
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
'200/7',
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
expect( state.items[ getItemsQueryId ] ).toBeDefined();
expect( state.items[ getItemsQueryId ].data ).toEqual( [
'200/7',
] );
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 1 );
} );
it( `with previous state,
and optimisticQueryUpdate with order_by: name,
and optimisticUrlParameters options`, () => {
const item: Item = {
id: 7,
name: 'kiwi',
status: 'draft',
};
const query = {
name: 'kiwi',
status: 'draft',
parent_id: 200,
};
const options = {
optimisticQueryUpdate: { parent_id: 200, order_by: 'name' },
optimisticUrlParameters: [ 200 ],
};
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const initialState: ResourceState = {
items: {
[ getItemsQueryId ]: {
data: [ '200/1', '200/2' ],
},
},
itemsCount: {
[ getItemsCountQueryId ]: 2,
},
errors: {},
data: {
'200/1': { id: 1, name: 'apple', status: 'draft' },
'200/2': { id: 2, name: 'pine', status: 'publish' },
},
requesting: {},
};
const state = reducer( initialState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options,
} );
expect( state.data ).toEqual( {
'200/1': {
id: 1,
name: 'apple',
status: 'draft',
},
'200/2': {
id: 2,
name: 'pine',
status: 'publish',
},
'200/7': {
id: 7,
name: 'kiwi',
status: 'draft',
},
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
'200/7',
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
expect( state.items[ getItemsQueryId ] ).toBeDefined();
expect( state.items[ getItemsQueryId ].data ).toEqual( [
'200/1',
'200/7',
'200/2',
] );
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 3 );
} );
it( `with previous state,
multiple items,
and optimisticQueryUpdate with order_by: name,
and optimisticUrlParameters options`, () => {
const item: Item = {
id: 9,
name: 'Mootools',
status: 'draft',
};
const query = {
name: 'Mootools',
status: 'draft',
parent_id: 500,
};
const options = {
optimisticQueryUpdate: { parent_id: 500, order_by: 'name' },
optimisticUrlParameters: [ 500 ],
};
const getItemsQueryId_200 = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
{
parent_id: 200,
rocking_by: 'name',
}
);
const getItemsQueryId_300 = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
{
parent_id: 300,
rocking_by: 'name',
}
);
const getItemsQueryId = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const getItemsCountQueryId_200 = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
{
parent_id: 200,
rocking_by: 'name',
}
);
const getItemsCountQueryId_300 = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
{
parent_id: 300,
order_by: 'name',
}
);
const getItemsCountQueryId = getTotalCountResourceName(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
);
const initialState: ResourceState = {
items: {
[ getItemsQueryId_200 ]: {
data: [ '500/1', '500/2' ],
},
[ getItemsQueryId_300 ]: {
data: [ '300/1', '300/2' ],
},
[ getItemsQueryId ]: {
data: [ '500/1', '500/2', '500/3' ],
},
},
itemsCount: {
[ getItemsCountQueryId_200 ]: 2,
[ getItemsCountQueryId_300 ]: 2,
[ getItemsCountQueryId ]: 3,
},
errors: {},
data: {
'200/1': { id: 1, name: 'apple', status: 'draft' },
'200/2': { id: 2, name: 'pine', status: 'publish' },
'300/1': { id: 1, name: 'cat', status: 'draft' },
'300/2': { id: 2, name: 'dog', status: 'draft' },
'500/1': { id: 1, name: 'jQuery', status: 'draft' },
'500/2': { id: 2, name: 'AlphaPro', status: 'draft' },
'500/3': { id: 3, name: 'Vue', status: 'draft' },
},
requesting: {},
};
const state = reducer( initialState, {
type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id,
item,
query,
options,
} );
expect( state.data ).toEqual( {
'200/1': { id: 1, name: 'apple', status: 'draft' },
'200/2': { id: 2, name: 'pine', status: 'publish' },
'300/1': { id: 1, name: 'cat', status: 'draft' },
'300/2': { id: 2, name: 'dog', status: 'draft' },
'500/1': { id: 1, name: 'jQuery', status: 'draft' },
'500/2': { id: 2, name: 'AlphaPro', status: 'draft' },
'500/3': { id: 3, name: 'Vue', status: 'draft' },
'500/9': { id: 9, name: 'Mootools', status: 'draft' }, // New item
} );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
'500/9',
query
);
expect( state.requesting[ resourceName ] ).toEqual( false );
expect( state.items[ getItemsQueryId ] ).toBeDefined();
expect( state.items[ getItemsQueryId ].data ).toEqual( [
'500/2',
'500/1',
'500/9',
'500/3',
] );
expect( state.itemsCount[ getItemsCountQueryId ] ).toEqual( 4 );
} );
} );

View File

@ -43,6 +43,7 @@ type WithRequiredProperty< Type, Key extends keyof Type > = Type & {
export type CrudActionOptions = {
optimisticQueryUpdate?: ItemQuery;
optimisticUrlParameters?: IdType[];
};
export type CrudActions<

View File

@ -96,6 +96,26 @@ export const organizeItemsById = (
return { objItems, ids };
};
/**
* Filters the input data object, returning a new object that contains only the keys
* specified in the keys array.
*
* @param {Record<string, unknown>} data - The original data object to filter.
* @param {IdType[]} keys - An array of keys that should be included in the returned object.
* @return {Record<string, unknown>} A new object containing only the specified keys.
*/
export function filterDataByKeys(
data: Record< string, unknown >,
keys: IdType[]
): Record< string, unknown > {
return keys.reduce( ( acc: Record< string, unknown >, key ) => {
if ( data[ key ] ) {
acc[ key ] = data[ key ];
}
return acc;
}, {} );
}
/**
* Parse an ID query into a ID string.
*