Skip to content

Commit

Permalink
Propose useEntityRecords (experimental) (#38782)
Browse files Browse the repository at this point in the history
* Propose useEntityRecords

* Expose __experimentalUseEntityRecords as a public API

* Pluralize the docstring

* Move the status computation inside the enriched selectors

* Remove the confusing unit test – it's covered by the other one
  • Loading branch information
adamziel authored Feb 15, 2022
1 parent 8d5cf87 commit 8b8886c
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 58 deletions.
32 changes: 6 additions & 26 deletions packages/core-data/src/hooks/test/use-entity-record.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ describe( 'useEntityRecord', () => {

const TEST_RECORD = { id: 1, hello: 'world' };

it( 'retrieves the relevant entity record', async () => {
it( 'resolves the entity record when missing from the state', async () => {
// Provide response
triggerFetch.mockImplementation( () => TEST_RECORD );

let data;
await registry
.dispatch( coreDataStore )
.receiveEntityRecords( 'root', 'widget', [ TEST_RECORD ] );
const TestComponent = () => {
data = useEntityRecord( 'root', 'widget', 1 );
return <div />;
Expand All @@ -47,34 +47,14 @@ describe( 'useEntityRecord', () => {
<TestComponent />
</RegistryProvider>
);

expect( data ).toEqual( {
record: TEST_RECORD,
records: undefined,
hasResolved: false,
isResolving: false,
status: 'IDLE',
} );

// Required to make sure no updates happen outside of act()
await act( async () => {
jest.advanceTimersByTime( 1 );
} );
} );

it( 'resolves the entity if missing from state', async () => {
// Provide response
triggerFetch.mockImplementation( () => TEST_RECORD );

let data;
const TestComponent = () => {
data = useEntityRecord( 'root', 'widget', 1 );
return <div />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);

await act( async () => {
jest.advanceTimersByTime( 1 );
} );
Expand Down
78 changes: 78 additions & 0 deletions packages/core-data/src/hooks/test/use-entity-records.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* WordPress dependencies
*/
import triggerFetch from '@wordpress/api-fetch';
import { createRegistry, RegistryProvider } from '@wordpress/data';

jest.mock( '@wordpress/api-fetch' );

/**
* External dependencies
*/
import { act, render } from '@testing-library/react';

/**
* Internal dependencies
*/
import { store as coreDataStore } from '../../index';
import useEntityRecords from '../use-entity-records';

describe( 'useEntityRecords', () => {
let registry;
beforeEach( () => {
jest.useFakeTimers();

registry = createRegistry();
registry.register( coreDataStore );
} );

afterEach( () => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
} );

const TEST_RECORDS = [
{ id: 1, hello: 'world1' },
{ id: 2, hello: 'world2' },
{ id: 3, hello: 'world3' },
];

it( 'resolves the entity records when missing from the state', async () => {
// Provide response
triggerFetch.mockImplementation( () => TEST_RECORDS );

let data;
const TestComponent = () => {
data = useEntityRecords( 'root', 'widget', { status: 'draft' } );
return <div />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);

expect( data ).toEqual( {
records: null,
hasResolved: false,
isResolving: false,
status: 'IDLE',
} );

await act( async () => {
jest.advanceTimersByTime( 1 );
} );

// Fetch request should have been issued
expect( triggerFetch ).toHaveBeenCalledWith( {
path: '/wp/v2/widgets?context=edit&status=draft',
} );

expect( data ).toEqual( {
records: TEST_RECORDS,
hasResolved: true,
isResolving: false,
status: 'SUCCESS',
} );
} );
} );
6 changes: 3 additions & 3 deletions packages/core-data/src/hooks/test/use-query-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ describe( 'useQuerySelect', () => {
expect( querySelectData ).toEqual( {
data: 'bar',
isResolving: false,
hasStarted: false,
hasResolved: false,
status: 'IDLE',
} );
} );

Expand Down Expand Up @@ -171,8 +171,8 @@ describe( 'useQuerySelect', () => {
expect( querySelectData ).toEqual( {
data: 10,
isResolving: false,
hasStarted: false,
hasResolved: false,
status: 'IDLE',
} );

await act( async () => {
Expand All @@ -188,8 +188,8 @@ describe( 'useQuerySelect', () => {
expect( querySelectData ).toEqual( {
data: 15,
isResolving: false,
hasStarted: true,
hasResolved: true,
status: 'SUCCESS',
} );
} );
} );
23 changes: 4 additions & 19 deletions packages/core-data/src/hooks/use-entity-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface EntityRecordResolution< RecordType > {
* ```
*
* In the above example, when `PageTitleDisplay` is rendered into an
* application, the price and the resolution details will be retrieved from
* application, the page and the resolution details will be retrieved from
* the store state using `getEntityRecord()`, or resolved if missing.
*
* @return {EntityRecordResolution<RecordType>} Entity record data.
Expand All @@ -60,28 +60,13 @@ export default function __experimentalUseEntityRecord< RecordType >(
name: string,
recordId: string | number
): EntityRecordResolution< RecordType > {
const { data, isResolving, hasResolved } = useQuerySelect(
const { data: record, ...rest } = useQuerySelect(
( query ) => query( coreStore ).getEntityRecord( kind, name, recordId ),
[ kind, name, recordId ]
);

let status;
if ( isResolving ) {
status = Status.Resolving;
} else if ( hasResolved ) {
if ( data ) {
status = Status.Success;
} else {
status = Status.Error;
}
} else {
status = Status.Idle;
}

return {
status,
record: data,
isResolving,
hasResolved,
record,
...rest,
};
}
79 changes: 79 additions & 0 deletions packages/core-data/src/hooks/use-entity-records.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Internal dependencies
*/
import useQuerySelect from './use-query-select';
import { store as coreStore } from '../';
import { Status } from './constants';

interface EntityRecordsResolution< RecordType > {
/** The requested entity record */
records: RecordType[] | null;

/**
* Is the record still being resolved?
*/
isResolving: boolean;

/**
* Is the record resolved by now?
*/
hasResolved: boolean;

/** Resolution status */
status: Status;
}

/**
* Resolves the specified entity records.
*
* @param kind Kind of the requested entities.
* @param name Name of the requested entities.
* @param queryArgs HTTP query for the requested entities.
*
* @example
* ```js
* import { useEntityRecord } from '@wordpress/core-data';
*
* function PageTitlesList() {
* const { records, isResolving } = useEntityRecords( 'postType', 'page' );
*
* if ( isResolving ) {
* return 'Loading...';
* }
*
* return (
* <ul>
* {records.map(( page ) => (
* <li>{ page.title }</li>
* ))}
* </ul>
* );
* }
*
* // Rendered in the application:
* // <PageTitlesList />
* ```
*
* In the above example, when `PageTitlesList` is rendered into an
* application, the list of records and the resolution details will be retrieved from
* the store state using `getEntityRecords()`, or resolved if missing.
*
* @return {EntityRecordsResolution<RecordType>} Entity records data.
* @template RecordType
*/
export default function __experimentalUseEntityRecords< RecordType >(
kind: string,
name: string,
queryArgs: unknown = {}
): EntityRecordsResolution< RecordType > {
const { data: records, ...rest } = useQuerySelect(
( query ) =>
query( coreStore ).getEntityRecords( kind, name, queryArgs ),
[ kind, name, queryArgs ]
);

return {
records,
...rest,
};
}
33 changes: 23 additions & 10 deletions packages/core-data/src/hooks/use-query-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSelect } from '@wordpress/data';
* Internal dependencies
*/
import memoize from './memoize';
import { Status } from './constants';

export const META_SELECTORS = [
'getIsResolving',
Expand Down Expand Up @@ -97,19 +98,31 @@ const enrichSelectors = memoize( ( selectors ) => {
}
Object.defineProperty( resolvers, selectorName, {
get: () => ( ...args ) => {
const {
getIsResolving,
hasStartedResolution,
hasFinishedResolution,
} = selectors;
const { getIsResolving, hasFinishedResolution } = selectors;
const isResolving = !! getIsResolving( selectorName, args );
const hasResolved =
! isResolving &&
hasFinishedResolution( selectorName, args );
const data = selectors[ selectorName ]( ...args );

let status;
if ( isResolving ) {
status = Status.Resolving;
} else if ( hasResolved ) {
if ( data ) {
status = Status.Success;
} else {
status = Status.Error;
}
} else {
status = Status.Idle;
}

return {
data: selectors[ selectorName ]( ...args ),
data,
status,
isResolving,
hasStarted: hasStartedResolution( selectorName, args ),
hasResolved:
! isResolving &&
hasFinishedResolution( selectorName, args ),
hasResolved,
};
},
} );
Expand Down
1 change: 1 addition & 0 deletions packages/core-data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,6 @@ register( store );

export { default as EntityProvider } from './entity-provider';
export { default as __experimentalUseEntityRecord } from './hooks/use-entity-record';
export { default as __experimentalUseEntityRecords } from './hooks/use-entity-records';
export * from './entity-provider';
export * from './fetch';

0 comments on commit 8b8886c

Please sign in to comment.