diff --git a/docs/data/data-core-editor.md b/docs/data/data-core-editor.md index 6ecd0aeab9e68b..0fc957c749a0c4 100644 --- a/docs/data/data-core-editor.md +++ b/docs/data/data-core-editor.md @@ -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 \ No newline at end of file diff --git a/docs/data/data-core.md b/docs/data/data-core.md index 9730c7cdecc6bb..5310fe2489c81e 100644 --- a/docs/data/data-core.md +++ b/docs/data/data-core.md @@ -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 @@ -182,4 +183,14 @@ a given URl has been received. *Parameters* * url: URL to preview the embed for. - * preview: Preview data. \ No newline at end of file + * 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. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3db9924963921a..06884a98691237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2165,7 +2165,7 @@ "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/data": "file:packages/data", "@wordpress/url": "file:packages/url", - "equivalent-key-map": "^0.2.1", + "equivalent-key-map": "^0.2.2", "lodash": "^4.17.10", "rememo": "^3.0.0" } @@ -2186,7 +2186,7 @@ "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/redux-routine": "file:packages/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" @@ -7041,9 +7041,9 @@ } }, "equivalent-key-map": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/equivalent-key-map/-/equivalent-key-map-0.2.1.tgz", - "integrity": "sha512-dmHJQuM7gMbZexuf9DfZdFUyeCUs2Srpa8Proo1TGC4YAF5UsLO+SNSFDvsf43kFJRLEKxpmGCAFNLOf6OjNPw==" + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/equivalent-key-map/-/equivalent-key-map-0.2.2.tgz", + "integrity": "sha512-xvHeyCDbZzkpN4VHQj/n+j2lOwL0VWszG30X4cOrc9Y7Tuo2qCdZK/0AMod23Z5dCtNUbaju6p0rwOhHUk05ew==" }, "errno": { "version": "0.1.7", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 2bdcb67dd005ae..138808f69a77d0 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -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" }, diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index bcba6c6c4b93b5..079ee7ad7eadc3 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, find } from 'lodash'; /** * Internal dependencies @@ -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. @@ -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 ); @@ -63,6 +66,7 @@ export function receiveEntityRecords( kind, name, records, query ) { ...action, kind, name, + invalidateCache, }; } @@ -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; +} diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 2ad80ad8a43451..84c4dc3b160b18 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -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' }, diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index bd6ccc11218095..1d8aa2fab6cb3d 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -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 }, } ); diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 013ab2d72d0ea9..2ddf82f9045f46 100755 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -12,6 +12,7 @@ import { replaceAction, onSubKey, } from '../utils'; +import { DEFAULT_ENTITY_KEY } from '../entities'; import getQueryParts from './get-query-parts'; /** @@ -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 ), }; } @@ -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; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 9a8d6a37d5cf08..6d286514a10f67 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -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 @@ -125,7 +125,7 @@ function entity( entityConfig ) { replaceAction( ( action ) => { return { ...action, - key: entityConfig.key || 'id', + key: entityConfig.key || DEFAULT_ENTITY_KEY, }; } ), ] )( queriedDataReducer ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 0ca1e3067d6211..6f6cd0e1d0a8c0 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -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. */ diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js new file mode 100644 index 00000000000000..86e7f50ed53c1e --- /dev/null +++ b/packages/core-data/src/test/actions.js @@ -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 ) ); + } ); +} ); diff --git a/packages/data/package.json b/packages/data/package.json index f4ebe59ba50d02..2f2195ed496c82 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -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" diff --git a/packages/data/src/components/with-dispatch/test/index.js b/packages/data/src/components/with-dispatch/test/index.js index 6a023fa236a98c..b187c5b69410c4 100644 --- a/packages/data/src/components/with-dispatch/test/index.js +++ b/packages/data/src/components/with-dispatch/test/index.js @@ -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 ) =>