From da1290d5820a2ef24176d1b87ade0a602f00e141 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 10 Oct 2023 11:25:59 +0100 Subject: [PATCH] Core Data: Retrieve the pagination totals in the getEntityRecords calls (#55164) --- docs/reference-guides/data/data-core.md | 31 ++++++++++ packages/core-data/CHANGELOG.md | 4 ++ packages/core-data/README.md | 31 ++++++++++ packages/core-data/src/actions.js | 8 ++- packages/core-data/src/entities.js | 2 + .../src/hooks/test/use-entity-records.js | 4 ++ .../core-data/src/hooks/use-entity-records.ts | 37 ++++++++++++ .../core-data/src/queried-data/actions.js | 9 ++- .../core-data/src/queried-data/reducer.js | 27 +++++---- .../core-data/src/queried-data/selectors.js | 14 ++++- .../src/queried-data/test/reducer.js | 18 +++--- .../src/queried-data/test/selectors.js | 16 ++--- packages/core-data/src/resolvers.js | 27 ++++++++- packages/core-data/src/selectors.ts | 60 ++++++++++++++++++- packages/core-data/src/test/resolvers.js | 5 +- packages/core-data/src/test/selectors.js | 8 ++- .../src/components/page-pages/index.js | 46 +++++--------- 17 files changed, 275 insertions(+), 72 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index acaaa5f23f1b98..8a190869f99e78 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -299,6 +299,36 @@ _Returns_ - `EntityRecord[] | null`: Records. +### getEntityRecordsTotalItems + +Returns the Entity's total available records for a given query (ignoring pagination). + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + +### getEntityRecordsTotalPages + +Returns the number of available pages for the given query. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. @@ -630,6 +660,7 @@ _Parameters_ - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. - _edits_ `?Object`: Edits to reset. +- _meta_ `?Object`: Meta information about pagination. _Returns_ diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index 99ad93050f6478..817a6cc924bb74 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## Enhancements + +- Add `getEntityRecordsTotalItems` and `getEntityRecordsTotalPages` selectors. [#55164](https://github.com/WordPress/gutenberg/pull/55164). + ## 6.20.0 (2023-10-05) ## 6.19.0 (2023-09-20) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 1493aee5bf22bb..a20e86e9695a26 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -217,6 +217,7 @@ _Parameters_ - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. - _edits_ `?Object`: Edits to reset. +- _meta_ `?Object`: Meta information about pagination. _Returns_ @@ -592,6 +593,36 @@ _Returns_ - `EntityRecord[] | null`: Records. +### getEntityRecordsTotalItems + +Returns the Entity's total available records for a given query (ignoring pagination). + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + +### getEntityRecordsTotalPages + +Returns the number of available pages for the given query. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3f6c3c12432f58..c4b19819ed7a41 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -80,6 +80,7 @@ export function addEntities( entities ) { * @param {?Object} query Query Object. * @param {?boolean} invalidateCache Should invalidate query caches. * @param {?Object} edits Edits to reset. + * @param {?Object} meta Meta information about pagination. * @return {Object} Action object. */ export function receiveEntityRecords( @@ -88,7 +89,8 @@ export function receiveEntityRecords( records, query, invalidateCache = false, - edits + edits, + meta ) { // Auto drafts should not have titles, but some plugins rely on them so we can't filter this // on the server. @@ -102,9 +104,9 @@ export function receiveEntityRecords( } let action; if ( query ) { - action = receiveQueriedItems( records, query, edits ); + action = receiveQueriedItems( records, query, edits, meta ); } else { - action = receiveItems( records, edits ); + action = receiveItems( records, edits, meta ); } return { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 1c952af4a05a86..af8829d0bc852c 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -120,6 +120,7 @@ export const rootEntitiesConfig = [ plural: 'mediaItems', label: __( 'Media' ), rawAttributes: [ 'caption', 'title', 'description' ], + supportsPagination: true, }, { name: 'taxonomy', @@ -326,6 +327,7 @@ async function loadPostTypeEntities() { }, syncObjectType: 'postType/' + postType.name, getSyncObjectId: ( id ) => id, + supportsPagination: true, }; } ); } diff --git a/packages/core-data/src/hooks/test/use-entity-records.js b/packages/core-data/src/hooks/test/use-entity-records.js index d75d2e8af849c9..af490b65096290 100644 --- a/packages/core-data/src/hooks/test/use-entity-records.js +++ b/packages/core-data/src/hooks/test/use-entity-records.js @@ -51,6 +51,8 @@ describe( 'useEntityRecords', () => { hasResolved: false, isResolving: false, status: 'IDLE', + totalItems: null, + totalPages: null, } ); // Fetch request should have been issued @@ -65,6 +67,8 @@ describe( 'useEntityRecords', () => { hasResolved: true, isResolving: false, status: 'SUCCESS', + totalItems: null, + totalPages: null, } ); } ); } ); diff --git a/packages/core-data/src/hooks/use-entity-records.ts b/packages/core-data/src/hooks/use-entity-records.ts index ff72ea4078c0e4..5d643ab8896925 100644 --- a/packages/core-data/src/hooks/use-entity-records.ts +++ b/packages/core-data/src/hooks/use-entity-records.ts @@ -3,6 +3,7 @@ */ import { addQueryArgs } from '@wordpress/url'; import deprecated from '@wordpress/deprecated'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -28,6 +29,16 @@ interface EntityRecordsResolution< RecordType > { /** Resolution status */ status: Status; + + /** + * The total number of available items (if not paginated). + */ + totalItems: number | null; + + /** + * The total number of pages. + */ + totalPages: number | null; } const EMPTY_ARRAY = []; @@ -97,8 +108,34 @@ export default function useEntityRecords< RecordType >( [ kind, name, queryAsString, options.enabled ] ); + const { totalItems, totalPages } = useSelect( + ( select ) => { + if ( ! options.enabled ) { + return { + totalItems: null, + totalPages: null, + }; + } + return { + totalItems: select( coreStore ).getEntityRecordsTotalItems( + kind, + name, + queryArgs + ), + totalPages: select( coreStore ).getEntityRecordsTotalPages( + kind, + name, + queryArgs + ), + }; + }, + [ kind, name, queryAsString, options.enabled ] + ); + return { records, + totalItems, + totalPages, ...rest, }; } diff --git a/packages/core-data/src/queried-data/actions.js b/packages/core-data/src/queried-data/actions.js index 58416713aebdb4..5a1a1acb48c64c 100644 --- a/packages/core-data/src/queried-data/actions.js +++ b/packages/core-data/src/queried-data/actions.js @@ -3,14 +3,16 @@ * * @param {Array} items Items received. * @param {?Object} edits Optional edits to reset. + * @param {?Object} meta Meta information about pagination. * * @return {Object} Action object. */ -export function receiveItems( items, edits ) { +export function receiveItems( items, edits, meta ) { return { type: 'RECEIVE_ITEMS', items: Array.isArray( items ) ? items : [ items ], persistedEdits: edits, + meta, }; } @@ -41,12 +43,13 @@ export function removeItems( kind, name, records, invalidateCache = false ) { * @param {Array} items Queried items received. * @param {?Object} query Optional query object. * @param {?Object} edits Optional edits to reset. + * @param {?Object} meta Meta information about pagination. * * @return {Object} Action object. */ -export function receiveQueriedItems( items, query = {}, edits ) { +export function receiveQueriedItems( items, query = {}, edits, meta ) { return { - ...receiveItems( items, edits ), + ...receiveItems( items, edits, meta ), query, }; } diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 8dfdca404e1a8c..40b0a9ba1f080b 100644 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -222,19 +222,22 @@ const receiveQueries = compose( [ // Queries shape is shared, but keyed by query `stableKey` part. Original // reducer tracks only a single query object. onSubKey( 'stableKey' ), -] )( ( state = null, action ) => { +] )( ( state = {}, action ) => { const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action; if ( type !== 'RECEIVE_ITEMS' ) { return state; } - return getMergedItemIds( - state || [], - action.items.map( ( item ) => item[ key ] ), - page, - perPage - ); + return { + itemIds: getMergedItemIds( + state?.itemIds || [], + action.items.map( ( item ) => item[ key ] ), + page, + perPage + ), + meta: action.meta, + }; } ); /** @@ -263,9 +266,13 @@ const queries = ( state = {}, action ) => { Object.entries( contextQueries ).map( ( [ query, queryItems ] ) => [ query, - queryItems.filter( - ( queryId ) => ! removedItems[ queryId ] - ), + { + ...queryItems, + itemIds: queryItems.itemIds.filter( + ( queryId ) => + ! removedItems[ queryId ] + ), + }, ] ) ), diff --git a/packages/core-data/src/queried-data/selectors.js b/packages/core-data/src/queried-data/selectors.js index 562aab7ce6b79b..ec2ef9beb23ae8 100644 --- a/packages/core-data/src/queried-data/selectors.js +++ b/packages/core-data/src/queried-data/selectors.js @@ -33,7 +33,7 @@ function getQueriedItemsUncached( state, query ) { let itemIds; if ( state.queries?.[ context ]?.[ stableKey ] ) { - itemIds = state.queries[ context ][ stableKey ]; + itemIds = state.queries[ context ][ stableKey ].itemIds; } if ( ! itemIds ) { @@ -118,3 +118,15 @@ export const getQueriedItems = createSelector( ( state, query = {} ) => { queriedItemsCache.set( query, items ); return items; } ); + +export function getQueriedTotalItems( state, query = {} ) { + const { stableKey, context } = getQueryParts( query ); + + return state.queries?.[ context ]?.[ stableKey ]?.meta?.totalItems ?? null; +} + +export function getQueriedTotalPages( state, query = {} ) { + const { stableKey, context } = getQueryParts( query ); + + return state.queries?.[ context ]?.[ stableKey ]?.meta?.totalPages ?? null; +} diff --git a/packages/core-data/src/queried-data/test/reducer.js b/packages/core-data/src/queried-data/test/reducer.js index 76657fca61da1b..4271f8d80a4a3e 100644 --- a/packages/core-data/src/queried-data/test/reducer.js +++ b/packages/core-data/src/queried-data/test/reducer.js @@ -159,7 +159,7 @@ describe( 'reducer', () => { default: { 1: true }, }, queries: { - default: { 's=a': [ 1 ] }, + default: { 's=a': { itemIds: [ 1 ] } }, }, } ); } ); @@ -200,8 +200,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 1, 2, 3, 4 ], - 's=a': [ 1, 3 ], + '': { itemIds: [ 1, 2, 3, 4 ] }, + 's=a': { itemIds: [ 1, 3 ] }, }, }, } ); @@ -218,8 +218,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 1, 2, 4 ], - 's=a': [ 1 ], + '': { itemIds: [ 1, 2, 4 ] }, + 's=a': { itemIds: [ 1 ] }, }, }, } ); @@ -238,8 +238,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 'foo//bar1', 'foo//bar2', 'foo//bar3' ], - 's=2': [ 'foo//bar2' ], + '': { itemIds: [ 'foo//bar1', 'foo//bar2', 'foo//bar3' ] }, + 's=2': { itemIds: [ 'foo//bar2' ] }, }, }, } ); @@ -258,8 +258,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 'foo//bar1', 'foo//bar3' ], - 's=2': [], + '': { itemIds: [ 'foo//bar1', 'foo//bar3' ] }, + 's=2': { itemIds: [] }, }, }, } ); diff --git a/packages/core-data/src/queried-data/test/selectors.js b/packages/core-data/src/queried-data/test/selectors.js index f0a38aab2887e1..3ec1e085c47bb2 100644 --- a/packages/core-data/src/queried-data/test/selectors.js +++ b/packages/core-data/src/queried-data/test/selectors.js @@ -32,7 +32,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; @@ -56,7 +56,7 @@ describe( 'getQueriedItems', () => { 2: true, }, }, - queries: [ 1, 2 ], + queries: { itemIds: [ 1, 2 ] }, }; const resultA = getQueriedItems( state, {} ); @@ -81,8 +81,8 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], - 'include=1': [ 1 ], + '': { itemIds: [ 1, 2 ] }, + 'include=1': { itemIds: [ 1 ] }, }, }, }; @@ -116,7 +116,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '_fields=content': [ 1, 2 ], + '_fields=content': { itemIds: [ 1, 2 ] }, }, }, }; @@ -161,7 +161,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '_fields=content%2Cmeta.template': [ 1, 2 ], + '_fields=content%2Cmeta.template': { itemIds: [ 1, 2 ] }, }, }, }; @@ -198,7 +198,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; @@ -230,7 +230,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index e8cf4e34a120ff..01b1db8d87d21d 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -228,7 +228,22 @@ export const getEntityRecords = ...query, } ); - let records = Object.values( await apiFetch( { path } ) ); + let records, meta; + if ( entityConfig.supportsPagination && query.per_page !== -1 ) { + const response = await apiFetch( { path, parse: false } ); + records = Object.values( await response.json() ); + meta = { + totalPages: parseInt( + response.headers.get( 'X-WP-TotalPages' ) + ), + totalItems: parseInt( + response.headers.get( 'X-WP-Total' ) + ), + }; + } else { + records = Object.values( await apiFetch( { path } ) ); + } + // If we request fields but the result doesn't contain the fields, // explicitly set these fields as "undefined" // that way we consider the query "fullfilled". @@ -244,7 +259,15 @@ export const getEntityRecords = } ); } - dispatch.receiveEntityRecords( kind, name, records, query ); + dispatch.receiveEntityRecords( + kind, + name, + records, + query, + false, + undefined, + meta + ); // When requesting all fields, the list of results can be used to // resolve the `getEntityRecord` selector in addition to `getEntityRecords`. diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 4e582bcf8a34ba..5d6fda85e557b6 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -14,7 +14,11 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import { STORE_NAME } from './name'; -import { getQueriedItems } from './queried-data'; +import { + getQueriedItems, + getQueriedTotalItems, + getQueriedTotalPages, +} from './queried-data'; import { DEFAULT_ENTITY_KEY } from './entities'; import { getNormalizedCommaSeparable, @@ -523,6 +527,60 @@ export const getEntityRecords = ( < return getQueriedItems( queriedState, query ); } ) as GetEntityRecords; +/** + * Returns the Entity's total available records for a given query (ignoring pagination). + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param query Optional terms query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + * + * @return number | null. + */ +export const getEntityRecordsTotalItems = ( + state: State, + kind: string, + name: string, + query: GetRecordsHttpQuery +): number | null => { + // Queried data state is prepopulated for all known entities. If this is not + // assigned for the given parameters, then it is known to not exist. + const queriedState = + state.entities.records?.[ kind ]?.[ name ]?.queriedData; + if ( ! queriedState ) { + return null; + } + return getQueriedTotalItems( queriedState, query ); +}; + +/** + * Returns the number of available pages for the given query. + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param query Optional terms query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + * + * @return number | null. + */ +export const getEntityRecordsTotalPages = ( + state: State, + kind: string, + name: string, + query: GetRecordsHttpQuery +): number | null => { + // Queried data state is prepopulated for all known entities. If this is not + // assigned for the given parameters, then it is known to not exist. + const queriedState = + state.entities.records?.[ kind ]?.[ name ]?.queriedData; + if ( ! queriedState ) { + return null; + } + return getQueriedTotalPages( queriedState, query ); +}; + type DirtyEntityRecord = { title: string; key: EntityRecordKey; diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 77487071d3f139..544caad0c2dbc4 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -172,7 +172,10 @@ describe( 'getEntityRecords', () => { 'root', 'postType', Object.values( POST_TYPES ), - {} + {}, + false, + undefined, + undefined ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 5ae7a81fa144e9..61c0621b6e5e49 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -226,7 +226,7 @@ describe( 'hasEntityRecords', () => { }, queries: { default: { - '': [ 'post', 'page' ], + '': { itemIds: [ 'post', 'page' ] }, }, }, }, @@ -361,7 +361,7 @@ describe( 'getEntityRecords', () => { }, queries: { default: { - '': [ 'post', 'page' ], + '': { itemIds: [ 'post', 'page' ] }, }, }, }, @@ -399,7 +399,9 @@ describe( 'getEntityRecords', () => { }, queries: { default: { - '_fields=id%2Ccontent': [ 1 ], + '_fields=id%2Ccontent': { + itemIds: [ 1 ], + }, }, }, }, diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index eea646c1c7326c..ef3c85ba92cff9 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -1,8 +1,6 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; import { VisuallyHidden, __experimentalHeading as Heading, @@ -11,7 +9,7 @@ import { import { __ } from '@wordpress/i18n'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; -import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -35,7 +33,6 @@ export default function PagePages() { direction: 'desc', }, } ); - const [ paginationInfo, setPaginationInfo ] = useState(); // Request post statuses to get the proper labels. const { records: statuses } = useEntityRecords( 'root', 'status' ); const postStatuses = @@ -58,33 +55,20 @@ export default function PagePages() { } ), [ view ] ); - const { records: pages, isResolving: isLoadingPages } = useEntityRecords( - 'postType', - 'page', - queryArgs + const { + records: pages, + isResolving: isLoadingPages, + totalItems, + totalPages, + } = useEntityRecords( 'postType', 'page', queryArgs ); + + const paginationInfo = useMemo( + () => ( { + totalItems, + totalPages, + } ), + [ totalItems, totalPages ] ); - useEffect( () => { - // Make extra request to handle controlled pagination. - apiFetch( { - path: addQueryArgs( '/wp/v2/pages', { - ...queryArgs, - _fields: 'id', - } ), - method: 'HEAD', - parse: false, - } ).then( ( res ) => { - // TODO: store this in core-data reducer and - // make sure it's returned as part of useEntityRecords - // (to avoid double requests). - const totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) ); - const totalItems = parseInt( res.headers.get( 'X-WP-Total' ) ); - setPaginationInfo( { - totalPages, - totalItems, - } ); - } ); - // Status should not make extra request if already did.. - }, [ queryArgs ] ); const fields = useMemo( () => [ @@ -158,7 +142,7 @@ export default function PagePages() { view={ view } onChangeView={ setView } options={ { - pageCount: paginationInfo?.totalPages, + pageCount: totalPages, } } />