Skip to content

Commit c3c8f46

Browse files
authored
Data: Support adding and updating entities (#10089)
1 parent 85d58bb commit c3c8f46

24 files changed

+402
-172
lines changed

docs/data/data-core-editor.md

+9
Original file line numberDiff line numberDiff line change
@@ -1749,4 +1749,13 @@ Returns an action object used to signal that post saving is unlocked.
17491749

17501750
* lockName: The lock name.
17511751

1752+
### addTermToEditedPost
1753+
1754+
Returns an action object signaling that a new term is added to the edited post.
1755+
1756+
*Parameters*
1757+
1758+
* slug: Taxonomy slug.
1759+
* term: Term object.
1760+
17521761
### createNotice

docs/data/data-core.md

+12-1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ Returns an action object used in signalling that entity records have been receiv
165165
* name: Name of the received entity.
166166
* records: Records received.
167167
* query: Query Object.
168+
* invalidateCache: Should invalidate query caches
168169

169170
### receiveThemeSupports
170171

@@ -182,4 +183,14 @@ a given URl has been received.
182183
*Parameters*
183184

184185
* url: URL to preview the embed for.
185-
* preview: Preview data.
186+
* preview: Preview data.
187+
188+
### saveEntityRecord
189+
190+
Action triggered to save an entity record.
191+
192+
*Parameters*
193+
194+
* kind: Kind of the received entity.
195+
* name: Name of the received entity.
196+
* record: Record to be saved.

package-lock.json

+5-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core-data/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@wordpress/api-fetch": "file:../api-fetch",
2626
"@wordpress/data": "file:../data",
2727
"@wordpress/url": "file:../url",
28-
"equivalent-key-map": "^0.2.1",
28+
"equivalent-key-map": "^0.2.2",
2929
"lodash": "^4.17.10",
3030
"rememo": "^3.0.0"
3131
},

packages/core-data/src/actions.js

+37-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* External dependencies
33
*/
4-
import { castArray } from 'lodash';
4+
import { castArray, find } from 'lodash';
55

66
/**
77
* Internal dependencies
@@ -10,6 +10,8 @@ import {
1010
receiveItems,
1111
receiveQueriedItems,
1212
} from './queried-data';
13+
import { getKindEntities, DEFAULT_ENTITY_KEY } from './entities';
14+
import { apiFetch } from './controls';
1315

1416
/**
1517
* Returns an action object used in signalling that authors have been received.
@@ -44,14 +46,15 @@ export function addEntities( entities ) {
4446
/**
4547
* Returns an action object used in signalling that entity records have been received.
4648
*
47-
* @param {string} kind Kind of the received entity.
48-
* @param {string} name Name of the received entity.
49-
* @param {Array|Object} records Records received.
50-
* @param {?Object} query Query Object.
49+
* @param {string} kind Kind of the received entity.
50+
* @param {string} name Name of the received entity.
51+
* @param {Array|Object} records Records received.
52+
* @param {?Object} query Query Object.
53+
* @param {?boolean} invalidateCache Should invalidate query caches
5154
*
5255
* @return {Object} Action object.
5356
*/
54-
export function receiveEntityRecords( kind, name, records, query ) {
57+
export function receiveEntityRecords( kind, name, records, query, invalidateCache = false ) {
5558
let action;
5659
if ( query ) {
5760
action = receiveQueriedItems( records, query );
@@ -63,6 +66,7 @@ export function receiveEntityRecords( kind, name, records, query ) {
6366
...action,
6467
kind,
6568
name,
69+
invalidateCache,
6670
};
6771
}
6872

@@ -96,3 +100,30 @@ export function receiveEmbedPreview( url, preview ) {
96100
preview,
97101
};
98102
}
103+
104+
/**
105+
* Action triggered to save an entity record.
106+
*
107+
* @param {string} kind Kind of the received entity.
108+
* @param {string} name Name of the received entity.
109+
* @param {Object} record Record to be saved.
110+
*
111+
* @return {Object} Updated record.
112+
*/
113+
export function* saveEntityRecord( kind, name, record ) {
114+
const entities = yield getKindEntities( kind );
115+
const entity = find( entities, { kind, name } );
116+
if ( ! entity ) {
117+
return;
118+
}
119+
const key = entity.key || DEFAULT_ENTITY_KEY;
120+
const recordId = record[ key ];
121+
const updatedRecord = yield apiFetch( {
122+
path: `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`,
123+
method: recordId ? 'PUT' : 'POST',
124+
data: record,
125+
} );
126+
yield receiveEntityRecords( kind, name, updatedRecord, undefined, true );
127+
128+
return updatedRecord;
129+
}

packages/core-data/src/entities.js

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { upperFirst, camelCase, map, find } from 'lodash';
99
import { addEntities } from './actions';
1010
import { apiFetch, select } from './controls';
1111

12+
export const DEFAULT_ENTITY_KEY = 'id';
13+
1214
export const defaultEntities = [
1315
{ name: 'postType', kind: 'root', key: 'slug', baseURL: '/wp/v2/types' },
1416
{ name: 'media', kind: 'root', baseURL: '/wp/v2/media', plural: 'mediaItems' },

packages/core-data/src/index.js

+19-9
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,37 @@ import * as resolvers from './resolvers';
1414
import { defaultEntities, getMethodName } from './entities';
1515
import { REDUCER_KEY } from './name';
1616

17-
const createEntityRecordSelector = ( source ) => defaultEntities.reduce( ( result, entity ) => {
17+
// The entity selectors/resolvers and actions are shortcuts to their generic equivalents
18+
// (getEntityRecord, getEntityRecords, updateEntityRecord, updateEntityRecordss)
19+
// Instead of getEntityRecord, the consumer could use more user-frieldly named selector: getPostType, getTaxonomy...
20+
// The "kind" and the "name" of the entity are combined to generate these shortcuts.
21+
22+
const entitySelectors = defaultEntities.reduce( ( result, entity ) => {
1823
const { kind, name } = entity;
19-
result[ getMethodName( kind, name ) ] = ( state, key ) => source.getEntityRecord( state, kind, name, key );
20-
result[ getMethodName( kind, name, 'get', true ) ] = ( state, ...args ) => source.getEntityRecords( state, kind, name, ...args );
24+
result[ getMethodName( kind, name ) ] = ( state, key ) => selectors.getEntityRecord( state, kind, name, key );
25+
result[ getMethodName( kind, name, 'get', true ) ] = ( state, ...args ) => selectors.getEntityRecords( state, kind, name, ...args );
2126
return result;
2227
}, {} );
2328

24-
const createEntityRecordResolver = ( source ) => defaultEntities.reduce( ( result, entity ) => {
29+
const entityResolvers = defaultEntities.reduce( ( result, entity ) => {
2530
const { kind, name } = entity;
26-
result[ getMethodName( kind, name ) ] = ( key ) => source.getEntityRecord( kind, name, key );
27-
result[ getMethodName( kind, name, 'get', true ) ] = ( ...args ) => source.getEntityRecords( kind, name, ...args );
31+
result[ getMethodName( kind, name ) ] = ( key ) => resolvers.getEntityRecord( kind, name, key );
32+
const pluralMethodName = getMethodName( kind, name, 'get', true );
33+
result[ pluralMethodName ] = ( ...args ) => resolvers.getEntityRecords( kind, name, ...args );
34+
result[ pluralMethodName ].shouldInvalidate = ( action, ...args ) => resolvers.getEntityRecords.shouldInvalidate( action, kind, name, ...args );
2835
return result;
2936
}, {} );
3037

31-
const entityResolvers = createEntityRecordResolver( resolvers );
32-
const entitySelectors = createEntityRecordSelector( selectors );
38+
const entityActions = defaultEntities.reduce( ( result, entity ) => {
39+
const { kind, name } = entity;
40+
result[ getMethodName( kind, name, 'save' ) ] = ( key ) => actions.saveEntityRecord( kind, name, key );
41+
return result;
42+
}, {} );
3343

3444
const store = registerStore( REDUCER_KEY, {
3545
reducer,
36-
actions,
3746
controls,
47+
actions: { ...actions, ...entityActions },
3848
selectors: { ...selectors, ...entitySelectors },
3949
resolvers: { ...resolvers, ...entityResolvers },
4050
} );

packages/core-data/src/queried-data/reducer.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
replaceAction,
1313
onSubKey,
1414
} from '../utils';
15+
import { DEFAULT_ENTITY_KEY } from '../entities';
1516
import getQueryParts from './get-query-parts';
1617

1718
/**
@@ -67,7 +68,7 @@ function items( state = {}, action ) {
6768
case 'RECEIVE_ITEMS':
6869
return {
6970
...state,
70-
...keyBy( action.items, action.key || 'id' ),
71+
...keyBy( action.items, action.key || DEFAULT_ENTITY_KEY ),
7172
};
7273
}
7374

@@ -107,7 +108,7 @@ const queries = flowRight( [
107108
// reducer tracks only a single query object.
108109
onSubKey( 'stableKey' ),
109110
] )( ( state = null, action ) => {
110-
const { type, page, perPage, key = 'id' } = action;
111+
const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action;
111112

112113
if ( type !== 'RECEIVE_ITEMS' ) {
113114
return state;

packages/core-data/src/reducer.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { combineReducers } from '@wordpress/data';
1313
*/
1414
import { ifMatchingAction, replaceAction } from './utils';
1515
import { reducer as queriedDataReducer } from './queried-data';
16-
import { defaultEntities } from './entities';
16+
import { defaultEntities, DEFAULT_ENTITY_KEY } from './entities';
1717

1818
/**
1919
* Reducer managing terms state. Keyed by taxonomy slug, the value is either
@@ -125,7 +125,7 @@ function entity( entityConfig ) {
125125
replaceAction( ( action ) => {
126126
return {
127127
...action,
128-
key: entityConfig.key || 'id',
128+
key: entityConfig.key || DEFAULT_ENTITY_KEY,
129129
};
130130
} ),
131131
] )( queriedDataReducer );

packages/core-data/src/resolvers.js

+9
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export function* getEntityRecords( kind, name, query = {} ) {
6666
yield receiveEntityRecords( kind, name, Object.values( records ), query );
6767
}
6868

69+
getEntityRecords.shouldInvalidate = ( action, kind, name ) => {
70+
return (
71+
action.type === 'RECEIVE_ITEMS' &&
72+
action.invalidateCache &&
73+
kind === action.kind &&
74+
name === action.name
75+
);
76+
};
77+
6978
/**
7079
* Requests theme supports data from the index.
7180
*/
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import { saveEntityRecord, receiveEntityRecords } from '../actions';
5+
6+
describe( 'saveEntityRecord', () => {
7+
it( 'triggers a POST request for a new record', async () => {
8+
const post = { title: 'new post' };
9+
const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ];
10+
const fulfillment = saveEntityRecord( 'postType', 'post', post );
11+
// Trigger generator
12+
fulfillment.next();
13+
// Provide entities and trigger apiFetch
14+
const { value: apiFetchAction } = fulfillment.next( entities );
15+
expect( apiFetchAction.request ).toEqual( {
16+
path: '/wp/v2/posts',
17+
method: 'POST',
18+
data: post,
19+
} );
20+
// Provide response and trigger action
21+
const { value: received } = fulfillment.next( { ...post, id: 10 } );
22+
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', { ...post, id: 10 }, undefined, true ) );
23+
} );
24+
25+
it( 'triggers a PUT request for an existing record', async () => {
26+
const post = { id: 10, title: 'new post' };
27+
const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ];
28+
const fulfillment = saveEntityRecord( 'postType', 'post', post );
29+
// Trigger generator
30+
fulfillment.next();
31+
// Provide entities and trigger apiFetch
32+
const { value: apiFetchAction } = fulfillment.next( entities );
33+
expect( apiFetchAction.request ).toEqual( {
34+
path: '/wp/v2/posts/10',
35+
method: 'PUT',
36+
data: post,
37+
} );
38+
// Provide response and trigger action
39+
const { value: received } = fulfillment.next( post );
40+
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', post, undefined, true ) );
41+
} );
42+
43+
it( 'triggers a PUT request for an existing record with a custom key', async () => {
44+
const postType = { slug: 'page', title: 'Pages' };
45+
const entities = [ { name: 'postType', kind: 'root', baseURL: '/wp/v2/types', key: 'slug' } ];
46+
const fulfillment = saveEntityRecord( 'root', 'postType', postType );
47+
// Trigger generator
48+
fulfillment.next();
49+
// Provide entities and trigger apiFetch
50+
const { value: apiFetchAction } = fulfillment.next( entities );
51+
expect( apiFetchAction.request ).toEqual( {
52+
path: '/wp/v2/types/page',
53+
method: 'PUT',
54+
data: postType,
55+
} );
56+
// Provide response and trigger action
57+
const { value: received } = fulfillment.next( postType );
58+
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', postType, undefined, true ) );
59+
} );
60+
} );

packages/data/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"@wordpress/element": "file:../element",
2727
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
2828
"@wordpress/redux-routine": "file:../redux-routine",
29-
"equivalent-key-map": "^0.2.0",
29+
"equivalent-key-map": "^0.2.2",
3030
"is-promise": "^2.1.0",
3131
"lodash": "^4.17.10",
3232
"redux": "^4.0.0"

packages/data/src/components/with-dispatch/test/index.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ describe( 'withDispatch', () => {
3333
const { count } = ownProps;
3434

3535
return {
36-
increment: () => _dispatch( 'counter' ).increment( count ),
36+
increment: () => {
37+
const actionReturnedFromDispatch = _dispatch( 'counter' ).increment( count );
38+
expect( actionReturnedFromDispatch ).toBe( undefined );
39+
},
3740
};
3841
} )( ( props ) => <button onClick={ props.increment } /> );
3942

packages/data/src/registry.js

+4-6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
*/
1515
import dataStore from './store';
1616
import promise from './promise-middleware';
17+
import createResolversCacheMiddleware from './resolvers-cache-middleware';
1718

1819
/**
1920
* An isolated orchestrator of store registrations.
@@ -67,7 +68,7 @@ export function createRegistry( storeConfigs = {} ) {
6768
*/
6869
function registerReducer( reducerKey, reducer ) {
6970
const enhancers = [
70-
applyMiddleware( promise ),
71+
applyMiddleware( createResolversCacheMiddleware( registry, reducerKey ), promise ),
7172
];
7273
if ( typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ) {
7374
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) );
@@ -117,11 +118,8 @@ export function createRegistry( storeConfigs = {} ) {
117118
*/
118119
function registerResolvers( reducerKey, newResolvers ) {
119120
namespaces[ reducerKey ].resolvers = mapValues( newResolvers, ( resolver ) => {
120-
if ( ! resolver.fulfill ) {
121-
resolver = { fulfill: resolver };
122-
}
123-
124-
return resolver;
121+
const { fulfill: resolverFulfill = resolver } = resolver;
122+
return { ...resolver, fulfill: resolverFulfill };
125123
} );
126124

127125
namespaces[ reducerKey ].selectors = mapValues( namespaces[ reducerKey ].selectors, ( selector, selectorName ) => {

0 commit comments

Comments
 (0)