Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data: Support adding and updating entities #10089

Merged
merged 19 commits into from
Oct 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/data/data-core-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -1749,4 +1749,13 @@ Returns an action object used to signal that post saving is unlocked.

* lockName: The lock name.

### addTermToEditedPost

Returns an action object signaling that a new term is added to the edited post.

*Parameters*

* slug: Taxonomy slug.
* term: Term object.

### createNotice
13 changes: 12 additions & 1 deletion docs/data/data-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ Returns an action object used in signalling that entity records have been receiv
* name: Name of the received entity.
* records: Records received.
* query: Query Object.
* invalidateCache: Should invalidate query caches

### receiveThemeSupports

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

* url: URL to preview the embed for.
* preview: Preview data.
* preview: Preview data.

### saveEntityRecord

Action triggered to save an entity record.

*Parameters*

* kind: Kind of the received entity.
* name: Name of the received entity.
* record: Record to be saved.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/data": "file:../data",
"@wordpress/url": "file:../url",
"equivalent-key-map": "^0.2.1",
"equivalent-key-map": "^0.2.2",
"lodash": "^4.17.10",
"rememo": "^3.0.0"
},
Expand Down
43 changes: 37 additions & 6 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { castArray } from 'lodash';
import { castArray, find } from 'lodash';

/**
* Internal dependencies
Expand All @@ -10,6 +10,8 @@ import {
receiveItems,
receiveQueriedItems,
} from './queried-data';
import { getKindEntities, DEFAULT_ENTITY_KEY } from './entities';
import { apiFetch } from './controls';

/**
* Returns an action object used in signalling that authors have been received.
Expand Down Expand Up @@ -44,14 +46,15 @@ export function addEntities( entities ) {
/**
* Returns an action object used in signalling that entity records have been received.
*
* @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.
* @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.
* @param {?boolean} invalidateCache Should invalidate query caches
*
* @return {Object} Action object.
*/
export function receiveEntityRecords( kind, name, records, query ) {
export function receiveEntityRecords( kind, name, records, query, invalidateCache = false ) {
let action;
if ( query ) {
action = receiveQueriedItems( records, query );
Expand All @@ -63,6 +66,7 @@ export function receiveEntityRecords( kind, name, records, query ) {
...action,
kind,
name,
invalidateCache,
};
}

Expand Down Expand Up @@ -96,3 +100,30 @@ export function receiveEmbedPreview( url, preview ) {
preview,
};
}

/**
* Action triggered to save an entity record.
*
* @param {string} kind Kind of the received entity.
* @param {string} name Name of the received entity.
* @param {Object} record Record to be saved.
*
* @return {Object} Updated record.
*/
export function* saveEntityRecord( kind, name, record ) {
const entities = yield getKindEntities( kind );
const entity = find( entities, { kind, name } );
if ( ! entity ) {
return;
}
const key = entity.key || DEFAULT_ENTITY_KEY;
const recordId = record[ key ];
const updatedRecord = yield apiFetch( {
path: `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`,
method: recordId ? 'PUT' : 'POST',
data: record,
} );
yield receiveEntityRecords( kind, name, updatedRecord, undefined, true );

return updatedRecord;
}
2 changes: 2 additions & 0 deletions packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { upperFirst, camelCase, map, find } from 'lodash';
import { addEntities } from './actions';
import { apiFetch, select } from './controls';

export const DEFAULT_ENTITY_KEY = 'id';

export const defaultEntities = [
{ name: 'postType', kind: 'root', key: 'slug', baseURL: '/wp/v2/types' },
{ name: 'media', kind: 'root', baseURL: '/wp/v2/media', plural: 'mediaItems' },
Expand Down
28 changes: 19 additions & 9 deletions packages/core-data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,37 @@ import * as resolvers from './resolvers';
import { defaultEntities, getMethodName } from './entities';
import { REDUCER_KEY } from './name';

const createEntityRecordSelector = ( source ) => defaultEntities.reduce( ( result, entity ) => {
// The entity selectors/resolvers and actions are shortcuts to their generic equivalents
// (getEntityRecord, getEntityRecords, updateEntityRecord, updateEntityRecordss)
// Instead of getEntityRecord, the consumer could use more user-frieldly named selector: getPostType, getTaxonomy...
// The "kind" and the "name" of the entity are combined to generate these shortcuts.

const entitySelectors = defaultEntities.reduce( ( result, entity ) => {
const { kind, name } = entity;
result[ getMethodName( kind, name ) ] = ( state, key ) => source.getEntityRecord( state, kind, name, key );
result[ getMethodName( kind, name, 'get', true ) ] = ( state, ...args ) => source.getEntityRecords( state, kind, name, ...args );
result[ getMethodName( kind, name ) ] = ( state, key ) => selectors.getEntityRecord( state, kind, name, key );
result[ getMethodName( kind, name, 'get', true ) ] = ( state, ...args ) => selectors.getEntityRecords( state, kind, name, ...args );
return result;
}, {} );

const createEntityRecordResolver = ( source ) => defaultEntities.reduce( ( result, entity ) => {
const entityResolvers = defaultEntities.reduce( ( result, entity ) => {
const { kind, name } = entity;
result[ getMethodName( kind, name ) ] = ( key ) => source.getEntityRecord( kind, name, key );
result[ getMethodName( kind, name, 'get', true ) ] = ( ...args ) => source.getEntityRecords( kind, name, ...args );
result[ getMethodName( kind, name ) ] = ( key ) => resolvers.getEntityRecord( kind, name, key );
const pluralMethodName = getMethodName( kind, name, 'get', true );
result[ pluralMethodName ] = ( ...args ) => resolvers.getEntityRecords( kind, name, ...args );
result[ pluralMethodName ].shouldInvalidate = ( action, ...args ) => resolvers.getEntityRecords.shouldInvalidate( action, kind, name, ...args );
return result;
}, {} );

const entityResolvers = createEntityRecordResolver( resolvers );
const entitySelectors = createEntityRecordSelector( selectors );
const entityActions = defaultEntities.reduce( ( result, entity ) => {
const { kind, name } = entity;
result[ getMethodName( kind, name, 'save' ) ] = ( key ) => actions.saveEntityRecord( kind, name, key );
return result;
}, {} );

const store = registerStore( REDUCER_KEY, {
reducer,
actions,
controls,
actions: { ...actions, ...entityActions },
selectors: { ...selectors, ...entitySelectors },
resolvers: { ...resolvers, ...entityResolvers },
} );
Expand Down
5 changes: 3 additions & 2 deletions packages/core-data/src/queried-data/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
replaceAction,
onSubKey,
} from '../utils';
import { DEFAULT_ENTITY_KEY } from '../entities';
import getQueryParts from './get-query-parts';

/**
Expand Down Expand Up @@ -67,7 +68,7 @@ function items( state = {}, action ) {
case 'RECEIVE_ITEMS':
return {
...state,
...keyBy( action.items, action.key || 'id' ),
...keyBy( action.items, action.key || DEFAULT_ENTITY_KEY ),
};
}

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

if ( type !== 'RECEIVE_ITEMS' ) {
return state;
Expand Down
4 changes: 2 additions & 2 deletions packages/core-data/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { combineReducers } from '@wordpress/data';
*/
import { ifMatchingAction, replaceAction } from './utils';
import { reducer as queriedDataReducer } from './queried-data';
import { defaultEntities } from './entities';
import { defaultEntities, DEFAULT_ENTITY_KEY } from './entities';

/**
* Reducer managing terms state. Keyed by taxonomy slug, the value is either
Expand Down Expand Up @@ -125,7 +125,7 @@ function entity( entityConfig ) {
replaceAction( ( action ) => {
return {
...action,
key: entityConfig.key || 'id',
key: entityConfig.key || DEFAULT_ENTITY_KEY,
};
} ),
] )( queriedDataReducer );
Expand Down
9 changes: 9 additions & 0 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export function* getEntityRecords( kind, name, query = {} ) {
yield receiveEntityRecords( kind, name, Object.values( records ), query );
}

getEntityRecords.shouldInvalidate = ( action, kind, name ) => {
return (
action.type === 'RECEIVE_ITEMS' &&
action.invalidateCache &&
kind === action.kind &&
name === action.name
);
};

/**
* Requests theme supports data from the index.
*/
Expand Down
60 changes: 60 additions & 0 deletions packages/core-data/src/test/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Internal dependencies
*/
import { saveEntityRecord, receiveEntityRecords } from '../actions';

describe( 'saveEntityRecord', () => {
it( 'triggers a POST request for a new record', async () => {
const post = { title: 'new post' };
const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ];
const fulfillment = saveEntityRecord( 'postType', 'post', post );
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
const { value: apiFetchAction } = fulfillment.next( entities );
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/posts',
method: 'POST',
data: post,
} );
// Provide response and trigger action
const { value: received } = fulfillment.next( { ...post, id: 10 } );
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', { ...post, id: 10 }, undefined, true ) );
} );

it( 'triggers a PUT request for an existing record', async () => {
const post = { id: 10, title: 'new post' };
const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ];
const fulfillment = saveEntityRecord( 'postType', 'post', post );
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
const { value: apiFetchAction } = fulfillment.next( entities );
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/posts/10',
method: 'PUT',
data: post,
} );
// Provide response and trigger action
const { value: received } = fulfillment.next( post );
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', post, undefined, true ) );
} );

it( 'triggers a PUT request for an existing record with a custom key', async () => {
const postType = { slug: 'page', title: 'Pages' };
const entities = [ { name: 'postType', kind: 'root', baseURL: '/wp/v2/types', key: 'slug' } ];
const fulfillment = saveEntityRecord( 'root', 'postType', postType );
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
const { value: apiFetchAction } = fulfillment.next( entities );
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/types/page',
method: 'PUT',
data: postType,
} );
// Provide response and trigger action
const { value: received } = fulfillment.next( postType );
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', postType, undefined, true ) );
} );
} );
2 changes: 1 addition & 1 deletion packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@wordpress/element": "file:../element",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/redux-routine": "file:../redux-routine",
"equivalent-key-map": "^0.2.0",
"equivalent-key-map": "^0.2.2",
"is-promise": "^2.1.0",
"lodash": "^4.17.10",
"redux": "^4.0.0"
Expand Down
5 changes: 4 additions & 1 deletion packages/data/src/components/with-dispatch/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ describe( 'withDispatch', () => {
const { count } = ownProps;

return {
increment: () => _dispatch( 'counter' ).increment( count ),
increment: () => {
const actionReturnedFromDispatch = _dispatch( 'counter' ).increment( count );
expect( actionReturnedFromDispatch ).toBe( undefined );
},
};
} )( ( props ) => <button onClick={ props.increment } /> );

Expand Down
10 changes: 4 additions & 6 deletions packages/data/src/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
*/
import dataStore from './store';
import promise from './promise-middleware';
import createResolversCacheMiddleware from './resolvers-cache-middleware';

/**
* An isolated orchestrator of store registrations.
Expand Down Expand Up @@ -67,7 +68,7 @@ export function createRegistry( storeConfigs = {} ) {
*/
function registerReducer( reducerKey, reducer ) {
const enhancers = [
applyMiddleware( promise ),
applyMiddleware( createResolversCacheMiddleware( registry, reducerKey ), promise ),
];
if ( typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ) {
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) );
Expand Down Expand Up @@ -117,11 +118,8 @@ export function createRegistry( storeConfigs = {} ) {
*/
function registerResolvers( reducerKey, newResolvers ) {
namespaces[ reducerKey ].resolvers = mapValues( newResolvers, ( resolver ) => {
if ( ! resolver.fulfill ) {
resolver = { fulfill: resolver };
}

return resolver;
const { fulfill: resolverFulfill = resolver } = resolver;
return { ...resolver, fulfill: resolverFulfill };
} );

namespaces[ reducerKey ].selectors = mapValues( namespaces[ reducerKey ].selectors, ( selector, selectorName ) => {
Expand Down
Loading