@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
293 lines (265 loc) • 8.14 kB
JavaScript
/**
* WordPress dependencies
*/
import { combineReducers } from '@wordpress/data';
import { compose } from '@wordpress/compose';
/**
* Internal dependencies
*/
import {
conservativeMapItem,
ifMatchingAction,
replaceAction,
onSubKey,
} from '../utils';
import { DEFAULT_ENTITY_KEY } from '../entities';
import getQueryParts from './get-query-parts';
function getContextFromAction( action ) {
const { query } = action;
if ( ! query ) {
return 'default';
}
const queryParts = getQueryParts( query );
return queryParts.context;
}
/**
* Returns a merged array of item IDs, given details of the received paginated
* items. The array is sparse-like with `undefined` entries where holes exist.
*
* @param {?Array<number>} itemIds Original item IDs (default empty array).
* @param {number[]} nextItemIds Item IDs to merge.
* @param {number} page Page of items merged.
* @param {number} perPage Number of items per page.
*
* @return {number[]} Merged array of item IDs.
*/
export function getMergedItemIds( itemIds, nextItemIds, page, perPage ) {
const receivedAllIds = page === 1 && perPage === -1;
if ( receivedAllIds ) {
return nextItemIds;
}
const nextItemIdsStartIndex = ( page - 1 ) * perPage;
// If later page has already been received, default to the larger known
// size of the existing array, else calculate as extending the existing.
const size = Math.max(
itemIds?.length ?? 0,
nextItemIdsStartIndex + nextItemIds.length
);
// Preallocate array since size is known.
const mergedItemIds = new Array( size );
for ( let i = 0; i < size; i++ ) {
// Preserve existing item ID except for subset of range of next items.
// We need to check against the possible maximum upper boundary because
// a page could receive fewer than what was previously stored.
const isInNextItemsRange =
i >= nextItemIdsStartIndex && i < nextItemIdsStartIndex + perPage;
mergedItemIds[ i ] = isInNextItemsRange
? nextItemIds[ i - nextItemIdsStartIndex ]
: itemIds?.[ i ];
}
return mergedItemIds;
}
/**
* Helper function to filter out entities with certain IDs.
* Entities are keyed by their ID.
*
* @param {Object} entities Entity objects, keyed by entity ID.
* @param {Array} ids Entity IDs to filter out.
*
* @return {Object} Filtered entities.
*/
function removeEntitiesById( entities, ids ) {
return Object.fromEntries(
Object.entries( entities ).filter(
( [ id ] ) =>
! ids.some( ( itemId ) => {
if ( Number.isInteger( itemId ) ) {
return itemId === +id;
}
return itemId === id;
} )
)
);
}
/**
* Reducer tracking items state, keyed by ID. Items are assumed to be normal,
* where identifiers are common across all queries.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Next state.
*/
export function items( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_ITEMS': {
const context = getContextFromAction( action );
const key = action.key || DEFAULT_ENTITY_KEY;
return {
...state,
[ context ]: {
...state[ context ],
...action.items.reduce( ( accumulator, value ) => {
const itemId = value?.[ key ];
accumulator[ itemId ] = conservativeMapItem(
state?.[ context ]?.[ itemId ],
value
);
return accumulator;
}, {} ),
},
};
}
case 'REMOVE_ITEMS':
return Object.fromEntries(
Object.entries( state ).map( ( [ itemId, contextState ] ) => [
itemId,
removeEntitiesById( contextState, action.itemIds ),
] )
);
}
return state;
}
/**
* Reducer tracking item completeness, keyed by ID. A complete item is one for
* which all fields are known. This is used in supporting `_fields` queries,
* where not all properties associated with an entity are necessarily returned.
* In such cases, completeness is used as an indication of whether it would be
* safe to use queried data for a non-`_fields`-limited request.
*
* @param {Object<string,Object<string,boolean>>} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object<string,Object<string,boolean>>} Next state.
*/
export function itemIsComplete( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_ITEMS': {
const context = getContextFromAction( action );
const { query, key = DEFAULT_ENTITY_KEY } = action;
// An item is considered complete if it is received without an associated
// fields query. Ideally, this would be implemented in such a way where the
// complete aggregate of all fields would satisfy completeness. Since the
// fields are not consistent across all entities, this would require
// introspection on the REST schema for each entity to know which fields
// compose a complete item for that entity.
const queryParts = query ? getQueryParts( query ) : {};
const isCompleteQuery =
! query || ! Array.isArray( queryParts.fields );
return {
...state,
[ context ]: {
...state[ context ],
...action.items.reduce( ( result, item ) => {
const itemId = item?.[ key ];
// Defer to completeness if already assigned. Technically the
// data may be outdated if receiving items for a field subset.
result[ itemId ] =
state?.[ context ]?.[ itemId ] || isCompleteQuery;
return result;
}, {} ),
},
};
}
case 'REMOVE_ITEMS':
return Object.fromEntries(
Object.entries( state ).map( ( [ itemId, contextState ] ) => [
itemId,
removeEntitiesById( contextState, action.itemIds ),
] )
);
}
return state;
}
/**
* Reducer tracking queries state, keyed by stable query key. Each reducer
* query object includes `itemIds` and `requestingPageByPerPage`.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Next state.
*/
const receiveQueries = compose( [
// Limit to matching action type so we don't attempt to replace action on
// an unhandled action.
ifMatchingAction( ( action ) => 'query' in action ),
// Inject query parts into action for use both in `onSubKey` and reducer.
replaceAction( ( action ) => {
// `ifMatchingAction` still passes on initialization, where state is
// undefined and a query is not assigned. Avoid attempting to parse
// parts. `onSubKey` will omit by lack of `stableKey`.
if ( action.query ) {
return {
...action,
...getQueryParts( action.query ),
};
}
return action;
} ),
onSubKey( 'context' ),
// Queries shape is shared, but keyed by query `stableKey` part. Original
// reducer tracks only a single query object.
onSubKey( 'stableKey' ),
] )( ( state = {}, action ) => {
const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action;
if ( type !== 'RECEIVE_ITEMS' ) {
return state;
}
return {
itemIds: getMergedItemIds(
state?.itemIds || [],
action.items.map( ( item ) => item?.[ key ] ).filter( Boolean ),
page,
perPage
),
meta: action.meta,
};
} );
/**
* Reducer tracking queries state.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Next state.
*/
const queries = ( state = {}, action ) => {
switch ( action.type ) {
case 'RECEIVE_ITEMS':
return receiveQueries( state, action );
case 'REMOVE_ITEMS':
const removedItems = action.itemIds.reduce( ( result, itemId ) => {
result[ itemId ] = true;
return result;
}, {} );
return Object.fromEntries(
Object.entries( state ).map(
( [ queryGroup, contextQueries ] ) => [
queryGroup,
Object.fromEntries(
Object.entries( contextQueries ).map(
( [ query, queryItems ] ) => [
query,
{
...queryItems,
itemIds: queryItems.itemIds.filter(
( queryId ) =>
! removedItems[ queryId ]
),
},
]
)
),
]
)
);
default:
return state;
}
};
export default combineReducers( {
items,
itemIsComplete,
queries,
} );