diff --git a/lib/client-assets.php b/lib/client-assets.php index 38a4c77cf1784..c21f72ae951b6 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -229,7 +229,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_script( 'wp-core-data', gutenberg_url( 'build/core-data/index.js' ), - array( 'wp-data', 'wp-api-fetch', 'lodash' ), + array( 'wp-data', 'wp-api-fetch', 'wp-url', 'lodash' ), filemtime( gutenberg_dir_path() . 'build/core-data/index.js' ), true ); diff --git a/package-lock.json b/package-lock.json index e6aa4b05a5715..977aabe5f52a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3381,6 +3381,8 @@ "@babel/runtime": "^7.0.0-beta.52", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/data": "file:packages/data", + "@wordpress/url": "file:packages/url", + "equivalent-key-map": "^0.2.1", "lodash": "^4.17.10", "rememo": "^3.0.0" } diff --git a/packages/core-data/package.json b/packages/core-data/package.json index a49b07006685e..b8372c66037ac 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -23,6 +23,8 @@ "@babel/runtime": "^7.0.0-beta.52", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/data": "file:../data", + "@wordpress/url": "file:../url", + "equivalent-key-map": "^0.2.1", "lodash": "^4.17.10", "rememo": "^3.0.0" }, diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 31107d8daef10..3353f48865940 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,6 +3,14 @@ */ import { castArray } from 'lodash'; +/** + * Internal dependencies + */ +import { + receiveItems, + receiveQueriedItems, +} from './queried-data'; + /** * Returns an action object used in signalling that terms have been received * for a given taxonomy. @@ -56,13 +64,20 @@ export function addEntities( entities ) { * @param {string} kind Kind of the received entity. * @param {string} name Name of the received entity. * @param {Array|Object} records Records received. + * @param {?Object} query Query Object. * * @return {Object} Action object. */ -export function receiveEntityRecords( kind, name, records ) { +export function receiveEntityRecords( kind, name, records, query ) { + let action; + if ( query ) { + action = receiveQueriedItems( records, query ); + } else { + action = receiveItems( records ); + } + return { - type: 'RECEIVE_ENTITY_RECORDS', - records: castArray( records ), + ...action, kind, name, }; diff --git a/packages/core-data/src/queried-data/actions.js b/packages/core-data/src/queried-data/actions.js new file mode 100755 index 0000000000000..87add69ad0d51 --- /dev/null +++ b/packages/core-data/src/queried-data/actions.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * Returns an action object used in signalling that items have been received. + * + * @param {Array} items Items received. + * + * @return {Object} Action object. + */ +export function receiveItems( items ) { + return { + type: 'RECEIVE_ITEMS', + items: castArray( items ), + }; +} + +/** + * Returns an action object used in signalling that queried data has been + * received. + * + * @param {Array} items Queried items received. + * @param {?Object} query Optional query object. + * + * @return {Object} Action object. + */ +export function receiveQueriedItems( items, query = {} ) { + return { + ...receiveItems( items ), + query, + }; +} diff --git a/packages/core-data/src/queried-data/get-query-parts.js b/packages/core-data/src/queried-data/get-query-parts.js new file mode 100755 index 0000000000000..61a934bbf049b --- /dev/null +++ b/packages/core-data/src/queried-data/get-query-parts.js @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { withWeakMapCache } from '../utils'; + +/** + * An object of properties describing a specific query. + * + * @typedef {WPQueriedDataQueryParts} + * + * @property {number} page The query page (1-based index, default 1). + * @property {number} perPage Items per page for query (default 10). + * @property {string} stableKey An encoded stable string of all non-pagination + * query parameters. + */ + +/** + * Given a query object, returns an object of parts, including pagination + * details (`page` and `perPage`, or default values). All other properties are + * encoded into a stable (idempotent) `stableKey` value. + * + * @param {Object} query Optional query object. + * + * @return {WPQueriedDataQueryParts} Query parts. + */ +export function getQueryParts( query ) { + /** + * @type {WPQueriedDataQueryParts} + */ + const parts = { + stableKey: '', + page: 1, + perPage: 10, + }; + + // Ensure stable key by sorting keys. Also more efficient for iterating. + const keys = Object.keys( query ).sort(); + + for ( let i = 0; i < keys.length; i++ ) { + const key = keys[ i ]; + const value = query[ key ]; + + switch ( key ) { + case 'page': + case 'perPage': + parts[ key ] = Number( value ); + break; + + default: + // While it could be any deterministic string, for simplicity's + // sake mimic querystring encoding for stable key. + // + // TODO: For consistency with PHP implementation, addQueryArgs + // should accept a key value pair, which may optimize its + // implementation for our use here, vs. iterating an object + // with only a single key. + parts.stableKey += ( + ( parts.stableKey ? '&' : '' ) + + addQueryArgs( '', { [ key ]: value } ).slice( 1 ) + ); + } + } + + return parts; +} + +export default withWeakMapCache( getQueryParts ); diff --git a/packages/core-data/src/queried-data/index.js b/packages/core-data/src/queried-data/index.js new file mode 100755 index 0000000000000..57e124e445d87 --- /dev/null +++ b/packages/core-data/src/queried-data/index.js @@ -0,0 +1,3 @@ +export * from './actions'; +export * from './selectors'; +export { default as reducer } from './reducer'; diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js new file mode 100755 index 0000000000000..013ab2d72d0ea --- /dev/null +++ b/packages/core-data/src/queried-data/reducer.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; +import { keyBy, map, flowRight } from 'lodash'; + +/** + * Internal dependencies + */ +import { + ifMatchingAction, + replaceAction, + onSubKey, +} from '../utils'; +import getQueryParts from './get-query-parts'; + +/** + * 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} 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 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, + 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. + const isInNextItemsRange = ( + i >= nextItemIdsStartIndex && + i < nextItemIdsStartIndex + nextItemIds.length + ); + + mergedItemIds[ i ] = isInNextItemsRange ? + nextItemIds[ i - nextItemIdsStartIndex ] : + itemIds[ i ]; + } + + return mergedItemIds; +} + +/** + * 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. + */ +function items( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_ITEMS': + return { + ...state, + ...keyBy( action.items, action.key || 'id' ), + }; + } + + 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 queries = flowRight( [ + // 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; + } ), + + // Queries shape is shared, but keyed by query `stableKey` part. Original + // reducer tracks only a single query object. + onSubKey( 'stableKey' ), +] )( ( state = null, action ) => { + const { type, page, perPage, key = 'id' } = action; + + if ( type !== 'RECEIVE_ITEMS' ) { + return state; + } + + return getMergedItemIds( + state || [], + map( action.items, key ), + page, + perPage + ); +} ); + +export default combineReducers( { + items, + queries, +} ); diff --git a/packages/core-data/src/queried-data/selectors.js b/packages/core-data/src/queried-data/selectors.js new file mode 100755 index 0000000000000..d671ead70089a --- /dev/null +++ b/packages/core-data/src/queried-data/selectors.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import EquivalentKeyMap from 'equivalent-key-map'; + +/** + * Internal dependencies + */ +import getQueryParts from './get-query-parts'; + +/** + * Cache of state keys to EquivalentKeyMap where the inner map tracks queries + * to their resulting items set. WeakMap allows garbage collection on expired + * state references. + * + * @type {WeakMap} + */ +const queriedItemsCacheByState = new WeakMap(); + +/** + * Returns items for a given query, or null if the items are not known. + * + * @param {Object} state State object. + * @param {?Object} query Optional query. + * + * @return {?Array} Query items. + */ +function getQueriedItemsUncached( state, query ) { + const { stableKey, page, perPage } = getQueryParts( query ); + if ( ! state.queries[ stableKey ] ) { + return null; + } + + const itemIds = state.queries[ stableKey ]; + if ( ! itemIds ) { + return null; + } + + const startOffset = ( page - 1 ) * perPage; + const endOffset = Math.min( + startOffset + perPage, + itemIds.length + ); + + const items = []; + for ( let i = startOffset; i < endOffset; i++ ) { + const itemId = itemIds[ i ]; + items.push( state.items[ itemId ] ); + } + + return items; +} + +/** + * Returns items for a given query, or null if the items are not known. Caches + * result both per state (by reference) and per query (by deep equality). + * The caching approach is intended to be durable to query objects which are + * deeply but not referentially equal, since otherwise: + * + * `getQueriedItems( state, {} ) !== getQueriedItems( state, {} )` + * + * @param {Object} state State object. + * @param {?Object} query Optional query. + * + * @return {?Array} Query items. + */ +export const getQueriedItems = createSelector( ( state, query = {} ) => { + let queriedItemsCache = queriedItemsCacheByState.get( state ); + if ( queriedItemsCache ) { + const queriedItems = queriedItemsCache.get( query ); + if ( queriedItems !== undefined ) { + return queriedItems; + } + } else { + queriedItemsCache = new EquivalentKeyMap(); + queriedItemsCacheByState.set( state, queriedItemsCache ); + } + + const items = getQueriedItemsUncached( state, query ); + queriedItemsCache.set( query, items ); + return items; +} ); diff --git a/packages/core-data/src/queried-data/test/get-query-parts.js b/packages/core-data/src/queried-data/test/get-query-parts.js new file mode 100755 index 0000000000000..a15716e1d7d7d --- /dev/null +++ b/packages/core-data/src/queried-data/test/get-query-parts.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import { getQueryParts } from '../get-query-parts'; + +describe( 'getQueryParts', () => { + it( 'parses out pagination data', () => { + const parts = getQueryParts( { page: 2, perPage: 2 } ); + + expect( parts ).toEqual( { + page: 2, + perPage: 2, + stableKey: '', + } ); + } ); + + it( 'encodes stable string key', () => { + const first = getQueryParts( { '?': '&', b: 2 } ); + const second = getQueryParts( { b: 2, '?': '&' } ); + + expect( first ).toEqual( second ); + expect( first ).toEqual( { + page: 1, + perPage: 10, + stableKey: '%3F=%26&b=2', + } ); + } ); + + it( 'encodes deep values', () => { + const parts = getQueryParts( { a: [ 1, 2 ] } ); + + expect( parts ).toEqual( { + page: 1, + perPage: 10, + stableKey: 'a%5B0%5D=1&a%5B1%5D=2', + } ); + } ); + + it( 'encodes stable string key with page data normalized to number', () => { + const first = getQueryParts( { b: 2, page: 1, perPage: 10 } ); + const second = getQueryParts( { b: 2, page: '1', perPage: '10' } ); + + expect( first ).toEqual( second ); + expect( first ).toEqual( { + page: 1, + perPage: 10, + stableKey: 'b=2', + } ); + } ); +} ); diff --git a/packages/core-data/src/queried-data/test/reducer.js b/packages/core-data/src/queried-data/test/reducer.js new file mode 100755 index 0000000000000..303b457f8a7cf --- /dev/null +++ b/packages/core-data/src/queried-data/test/reducer.js @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer, { + getMergedItemIds, +} from '../reducer'; + +describe( 'getMergedItemIds', () => { + it( 'should receive a page', () => { + const result = getMergedItemIds( [], [ 4, 5, 6 ], 2, 3 ); + + expect( result ).toEqual( [ + undefined, + undefined, + undefined, + 4, + 5, + 6, + ] ); + } ); + + it( 'should merge into existing items', () => { + const original = deepFreeze( [ + undefined, + undefined, + undefined, + 4, + 5, + 6, + ] ); + const result = getMergedItemIds( original, [ 1, 2, 3 ], 1, 3 ); + + expect( result ).toEqual( [ + 1, + 2, + 3, + 4, + 5, + 6, + ] ); + } ); + + it( 'should replace with new page', () => { + const original = deepFreeze( [ + 1, + 2, + 3, + 4, + 5, + 6, + ] ); + const result = getMergedItemIds( original, [ 'replaced', 5, 6 ], 2, 3 ); + + expect( result ).toEqual( [ + 1, + 2, + 3, + 'replaced', + 5, + 6, + ] ); + } ); + + it( 'should append a new partial page', () => { + const original = deepFreeze( [ + 1, + 2, + 3, + 4, + 5, + 6, + ] ); + const result = getMergedItemIds( original, [ 7 ], 3, 3 ); + + expect( result ).toEqual( [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ] ); + } ); +} ); + +describe( 'reducer', () => { + it( 'returns a default value of its combined keys defaults', () => { + const state = reducer( undefined, {} ); + + expect( state ).toEqual( { + items: {}, + queries: {}, + } ); + } ); + + it( 'receives a page of queried data', () => { + const original = deepFreeze( { + items: {}, + queries: {}, + } ); + const state = reducer( original, { + type: 'RECEIVE_ITEMS', + query: { s: 'a', page: 1, perPage: 3 }, + items: [ + { id: 1, name: 'abc' }, + ], + } ); + + expect( state ).toEqual( { + items: { + 1: { id: 1, name: 'abc' }, + }, + queries: { + 's=a': [ 1 ], + }, + } ); + } ); + + it( 'receives an unqueried page of items', () => { + const original = deepFreeze( { + items: {}, + queries: {}, + } ); + const state = reducer( original, { + type: 'RECEIVE_ITEMS', + items: [ + { id: 1, name: 'abc' }, + ], + } ); + + expect( state ).toEqual( { + items: { + 1: { id: 1, name: 'abc' }, + }, + queries: {}, + } ); + } ); +} ); diff --git a/packages/core-data/src/queried-data/test/selectors.js b/packages/core-data/src/queried-data/test/selectors.js new file mode 100755 index 0000000000000..4d6b4f59509ff --- /dev/null +++ b/packages/core-data/src/queried-data/test/selectors.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import { getQueriedItems } from '../selectors'; + +describe( 'getQueriedItems', () => { + it( 'should return null if requesting but no item IDs', () => { + const state = { + items: {}, + queries: {}, + }; + + const result = getQueriedItems( state ); + + expect( result ).toBe( null ); + } ); + + it( 'should return an array of items', () => { + const state = { + items: { + 1: { id: 1 }, + 2: { id: 2 }, + }, + queries: { + '': [ 1, 2 ], + }, + }; + + const result = getQueriedItems( state ); + + expect( result ).toEqual( [ + { id: 1 }, + { id: 2 }, + ] ); + } ); + + it( 'should cache on query by state', () => { + const state = { + items: { + 1: { id: 1 }, + 2: { id: 2 }, + }, + queries: [ 1, 2 ], + }; + + const resultA = getQueriedItems( state, {} ); + const resultB = getQueriedItems( state, {} ); + + expect( resultA ).toBe( resultB ); + } ); +} ); diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 5f582890b44e4..cc02fe1d47019 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { keyBy, map, groupBy } from 'lodash'; +import { keyBy, map, groupBy, flowRight } from 'lodash'; /** * WordPress dependencies @@ -11,6 +11,8 @@ import { combineReducers } from '@wordpress/data'; /** * Internal dependencies */ +import { ifMatchingAction, replaceAction } from './utils'; +import { reducer as queriedDataReducer } from './queried-data'; import { defaultEntities } from './entities'; /** @@ -109,30 +111,24 @@ export function themeSupports( state = {}, action ) { * @return {Function} Reducer. */ function entity( entityConfig ) { - const key = entityConfig.key || 'id'; - - return ( state = { byKey: {} }, action ) => { - if ( - ! action.name || - ! action.kind || - action.name !== entityConfig.name || - action.kind !== entityConfig.kind - ) { - return state; - } - - switch ( action.type ) { - case 'RECEIVE_ENTITY_RECORDS': - return { - byKey: { - ...state.byKey, - ...keyBy( action.records, key ), - }, - }; - default: - return state; - } - }; + return flowRight( [ + // Limit to matching action type so we don't attempt to replace action on + // an unhandled action. + ifMatchingAction( ( action ) => ( + action.name && + action.kind && + action.name === entityConfig.name && + action.kind === entityConfig.kind + ) ), + + // Inject the entity config into the action. + replaceAction( ( action ) => { + return { + ...action, + key: entityConfig.key || 'id', + }; + } ), + ] )( queriedDataReducer ); } /** diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a105997bfedd6..d1594daf60c60 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -7,6 +7,7 @@ import { find } from 'lodash'; * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -57,18 +58,23 @@ export async function* getEntityRecord( state, kind, name, key ) { /** * Requests the entity's records from the REST API. * - * @param {Object} state State tree - * @param {string} kind Entity kind. - * @param {string} name Entity name. + * @param {Object} state State tree + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {Object?} query Query Object. */ -export async function* getEntityRecords( state, kind, name ) { +export async function* getEntityRecords( state, kind, name, query = {} ) { const entities = yield* await getKindEntities( state, kind ); const entity = find( entities, { kind, name } ); if ( ! entity ) { return; } - const records = await apiFetch( { path: `${ entity.baseURL }?context=edit` } ); - yield receiveEntityRecords( kind, name, Object.values( records ) ); + const path = addQueryArgs( entity.baseURL, { + ...query, + context: 'edit', + } ); + const records = await apiFetch( { path } ); + yield receiveEntityRecords( kind, name, Object.values( records ), query ); } /** diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 7d36a4c65558a..d01ea3d3b9c7e 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -13,6 +13,7 @@ import { select } from '@wordpress/data'; * Internal dependencies */ import { REDUCER_KEY } from './name'; +import { getQueriedItems } from './queried-data'; /** * Returns true if resolution is in progress for the core selector of the given @@ -139,24 +140,26 @@ export function getEntity( state, kind, name ) { * @return {Object?} Record. */ export function getEntityRecord( state, kind, name, key ) { - return get( state.entities.data, [ kind, name, 'byKey', key ] ); + return get( state.entities.data, [ kind, name, 'items', key ] ); } /** * Returns the Entity's records. * - * @param {Object} state State tree - * @param {string} kind Entity kind. - * @param {string} name Entity name. + * @param {Object} state State tree + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {?Object} query Optional terms query. * * @return {Array} Records. */ -export const getEntityRecords = createSelector( - ( state, kind, name ) => { - return Object.values( get( state.entities.data, [ kind, name, 'byKey' ] ) ); - }, - ( state, kind, name ) => [ get( state.entities.data, [ kind, name, 'byKey' ] ) ] -); +export function getEntityRecords( state, kind, name, query ) { + const queriedState = get( state.entities.data, [ kind, name ] ); + if ( ! queriedState ) { + return []; + } + return getQueriedItems( queriedState, query ); +} /** * Return theme supports data in the index. diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 781056e89009e..f25462746f7ee 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -34,23 +34,24 @@ describe( 'entities', () => { it( 'returns the default state for all defined entities', () => { const state = entities( undefined, {} ); - expect( state.data.root.postType ).toEqual( { byKey: {} } ); + expect( state.data.root.postType ).toEqual( { items: {}, queries: {} } ); } ); it( 'returns with received post types by slug', () => { const originalState = deepFreeze( {} ); const state = entities( originalState, { - type: 'RECEIVE_ENTITY_RECORDS', - records: [ { slug: 'b', title: 'beach' }, { slug: 's', title: 'sun' } ], + type: 'RECEIVE_ITEMS', + items: [ { slug: 'b', title: 'beach' }, { slug: 's', title: 'sun' } ], kind: 'root', name: 'postType', } ); expect( state.data.root.postType ).toEqual( { - byKey: { + items: { b: { slug: 'b', title: 'beach' }, s: { slug: 's', title: 'sun' }, }, + queries: {}, } ); } ); @@ -59,25 +60,27 @@ describe( 'entities', () => { data: { root: { postType: { - byKey: { + items: { w: { slug: 'w', title: 'water' }, }, + queries: {}, }, }, }, } ); const state = entities( originalState, { - type: 'RECEIVE_ENTITY_RECORDS', - records: [ { slug: 'b', title: 'beach' } ], + type: 'RECEIVE_ITEMS', + items: [ { slug: 'b', title: 'beach' } ], kind: 'root', name: 'postType', } ); expect( state.data.root.postType ).toEqual( { - byKey: { + items: { w: { slug: 'w', title: 'water' }, b: { slug: 'b', title: 'beach' }, }, + queries: {}, } ); } ); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index c721f1ddd895e..ee5d242255d22 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -102,6 +102,6 @@ describe( 'getEntityRecords', () => { }; const fulfillment = getEntityRecords( state, 'root', 'postType' ); const received = ( await fulfillment.next() ).value; - expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ) ) ); + expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ), {} ) ); } ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index ce3b68cf1b4d6..a2080f3a4c1bd 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -73,7 +73,8 @@ describe( 'getEntityRecord', () => { data: { root: { postType: { - byKey: {}, + items: {}, + queries: {}, }, }, }, @@ -88,9 +89,10 @@ describe( 'getEntityRecord', () => { data: { root: { postType: { - byKey: { + items: { post: { slug: 'post' }, }, + queries: {}, }, }, }, @@ -101,19 +103,20 @@ describe( 'getEntityRecord', () => { } ); describe( 'getEntityRecords', () => { - it( 'should return an empty array by default', () => { + it( 'should return an null by default', () => { const state = deepFreeze( { entities: { data: { root: { postType: { - byKey: {}, + items: {}, + queries: {}, }, }, }, }, } ); - expect( getEntityRecords( state, 'root', 'postType' ) ).toEqual( [] ); + expect( getEntityRecords( state, 'root', 'postType' ) ).toBe( null ); } ); it( 'should return all the records', () => { @@ -122,10 +125,13 @@ describe( 'getEntityRecords', () => { data: { root: { postType: { - byKey: { + items: { post: { slug: 'post' }, page: { slug: 'page' }, }, + queries: { + '': [ 'post', 'page' ], + }, }, }, }, diff --git a/packages/core-data/src/utils/if-matching-action.js b/packages/core-data/src/utils/if-matching-action.js new file mode 100755 index 0000000000000..bbbe35800187b --- /dev/null +++ b/packages/core-data/src/utils/if-matching-action.js @@ -0,0 +1,18 @@ +/** + * A higher-order reducer creator which invokes the original reducer only if + * the dispatching action matches the given predicate, **OR** if state is + * initializing (undefined). + * + * @param {Function} isMatch Function predicate for allowing reducer call. + * + * @return {Function} Higher-order reducer. + */ +const ifMatchingAction = ( isMatch ) => ( reducer ) => ( state, action ) => { + if ( state === undefined || isMatch( action ) ) { + return reducer( state, action ); + } + + return state; +}; + +export default ifMatchingAction; diff --git a/packages/core-data/src/utils/index.js b/packages/core-data/src/utils/index.js new file mode 100755 index 0000000000000..a52cb83430dcb --- /dev/null +++ b/packages/core-data/src/utils/index.js @@ -0,0 +1,4 @@ +export { default as ifMatchingAction } from './if-matching-action'; +export { default as onSubKey } from './on-sub-key'; +export { default as replaceAction } from './replace-action'; +export { default as withWeakMapCache } from './with-weak-map-cache'; diff --git a/packages/core-data/src/utils/on-sub-key.js b/packages/core-data/src/utils/on-sub-key.js new file mode 100755 index 0000000000000..0c525da246f42 --- /dev/null +++ b/packages/core-data/src/utils/on-sub-key.js @@ -0,0 +1,30 @@ +/** + * Higher-order reducer creator which creates a combined reducer object, keyed + * by a property on the action object. + * + * @param {string} actionProperty Action property by which to key object. + * + * @return {Function} Higher-order reducer. + */ +export const onSubKey = ( actionProperty ) => ( reducer ) => ( state = {}, action ) => { + // Retrieve subkey from action. Do not track if undefined; useful for cases + // where reducer is scoped by action shape. + const key = action[ actionProperty ]; + if ( key === undefined ) { + return state; + } + + // Avoid updating state if unchanged. Note that this also accounts for a + // reducer which returns undefined on a key which is not yet tracked. + const nextKeyState = reducer( state[ key ], action ); + if ( nextKeyState === state[ key ] ) { + return state; + } + + return { + ...state, + [ key ]: nextKeyState, + }; +}; + +export default onSubKey; diff --git a/packages/core-data/src/utils/replace-action.js b/packages/core-data/src/utils/replace-action.js new file mode 100755 index 0000000000000..91cecb0e39151 --- /dev/null +++ b/packages/core-data/src/utils/replace-action.js @@ -0,0 +1,13 @@ +/** + * Higher-order reducer creator which substitutes the action object before + * passing to the original reducer. + * + * @param {Function} replacer Function mapping original action to replacement. + * + * @return {Function} Higher-order reducer. + */ +const replaceAction = ( replacer ) => ( reducer ) => ( state, action ) => { + return reducer( state, replacer( action ) ); +}; + +export default replaceAction; diff --git a/packages/core-data/src/utils/test/if-matching-action.js b/packages/core-data/src/utils/test/if-matching-action.js new file mode 100755 index 0000000000000..0caf6290cf21e --- /dev/null +++ b/packages/core-data/src/utils/test/if-matching-action.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import ifMatchingAction from '../if-matching-action'; + +describe( 'ifMatchingAction', () => { + function createEnhancedReducer( predicate ) { + const enhanceReducer = ifMatchingAction( predicate ); + return enhanceReducer( () => 'Called' ); + } + + it( 'should call reducer if predicate returns true', () => { + const reducer = createEnhancedReducer( () => true ); + const nextState = reducer( 'Not Called', { type: '@@INIT' } ); + + expect( nextState ).toBe( 'Called' ); + } ); + + it( 'should not call reducer if predicate returns false', () => { + const state = deepFreeze( {} ); + const reducer = createEnhancedReducer( () => false ); + const nextState = reducer( state, { type: 'DO_FOO' } ); + + expect( nextState ).toBe( state ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/on-sub-key.js b/packages/core-data/src/utils/test/on-sub-key.js new file mode 100755 index 0000000000000..22fe81c047bc0 --- /dev/null +++ b/packages/core-data/src/utils/test/on-sub-key.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import onSubKey from '../on-sub-key'; + +describe( 'onSubKey', () => { + function createEnhancedReducer( actionProperty ) { + const enhanceReducer = onSubKey( actionProperty ); + return enhanceReducer( ( state, action ) => 'Called by ' + action.caller ); + } + + it( 'should default to an empty object', () => { + const reducer = createEnhancedReducer( 'caller' ); + const nextState = reducer( undefined, { type: '@@INIT' } ); + + expect( nextState ).toEqual( {} ); + } ); + + it( 'should ignore actions where property not present', () => { + const state = deepFreeze( {} ); + const reducer = createEnhancedReducer( 'caller' ); + const nextState = reducer( state, { type: 'DO_FOO' } ); + + expect( nextState ).toBe( state ); + } ); + + it( 'should key by action property', () => { + const reducer = createEnhancedReducer( 'caller' ); + + let state = deepFreeze( {} ); + state = reducer( state, { type: 'DO_FOO', caller: 1 } ); + state = reducer( state, { type: 'DO_FOO', caller: 2 } ); + + expect( state ).toEqual( { + 1: 'Called by 1', + 2: 'Called by 2', + } ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/replace-action.js b/packages/core-data/src/utils/test/replace-action.js new file mode 100755 index 0000000000000..8007e3ef502fd --- /dev/null +++ b/packages/core-data/src/utils/test/replace-action.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import replaceAction from '../replace-action'; + +describe( 'replaceAction', () => { + function createEnhancedReducer( replacer ) { + const enhanceReducer = replaceAction( replacer ); + return enhanceReducer( ( state, action ) => 'Called by ' + action.after ); + } + + it( 'should replace the action passed to the reducer', () => { + const reducer = createEnhancedReducer( ( action ) => ( { after: action.before } ) ); + const state = reducer( undefined, { before: 'foo' } ); + + expect( state ).toBe( 'Called by foo' ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/with-weak-map-cache.js b/packages/core-data/src/utils/test/with-weak-map-cache.js new file mode 100755 index 0000000000000..a7db434dc4490 --- /dev/null +++ b/packages/core-data/src/utils/test/with-weak-map-cache.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import withWeakMapCache from '../with-weak-map-cache'; + +describe( 'withWeakMapCache', () => { + it( 'calls and returns from the original function', () => { + const cachedFn = withWeakMapCache( () => 'Called' ); + const result = cachedFn(); + + expect( result ).toBe( 'Called' ); + } ); + + it( 'caches by weak reference', () => { + const a = {}; + const b = {}; + const fn = jest.fn().mockReturnValue( 'Called' ); + const cachedFn = withWeakMapCache( fn ); + + cachedFn( a ); + cachedFn( a ); + expect( fn ).toHaveBeenCalledTimes( 1 ); + + cachedFn( b ); + expect( fn ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/packages/core-data/src/utils/with-weak-map-cache.js b/packages/core-data/src/utils/with-weak-map-cache.js new file mode 100755 index 0000000000000..023284d202aba --- /dev/null +++ b/packages/core-data/src/utils/with-weak-map-cache.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { isObjectLike } from 'lodash'; + +/** + * Given a function, returns an enhanced function which caches the result and + * tracks in WeakMap. The result is only cached if the original function is + * passed a valid object-like argument (requirement for WeakMap key). + * + * @param {Function} fn Original function. + * + * @return {Function} Enhanced caching function. + */ +function withWeakMapCache( fn ) { + const cache = new WeakMap(); + + return function( key ) { + let value; + if ( cache.has( key ) ) { + value = cache.get( key ); + } else { + value = fn( key ); + + // Can reach here if key is not valid for WeakMap, since `has` + // will return false for invalid key. Since `set` will throw, + // ensure that key is valid before setting into cache. + if ( isObjectLike( key ) ) { + cache.set( key, value ); + } + } + + return value; + }; +} + +export default withWeakMapCache;