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 ) => );
diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js
index 2ed7217021563d..b647934bfa8210 100644
--- a/packages/data/src/registry.js
+++ b/packages/data/src/registry.js
@@ -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.
@@ -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 } ) );
@@ -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 ) => {
diff --git a/packages/data/src/resolvers-cache-middleware.js b/packages/data/src/resolvers-cache-middleware.js
new file mode 100644
index 00000000000000..0bc57390795669
--- /dev/null
+++ b/packages/data/src/resolvers-cache-middleware.js
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import { get } from 'lodash';
+
+/**
+ * creates a middleware handling resolvers cache invalidation.
+ *
+ * @param {Object} registry
+ * @param {string} reducerKey
+ *
+ * @return {function} middleware
+ */
+const createResolversCacheMiddleware = ( registry, reducerKey ) => () => ( next ) => ( action ) => {
+ const resolvers = registry.select( 'core/data' ).getCachedResolvers( reducerKey );
+ Object.entries( resolvers ).forEach( ( [ selectorName, resolversByArgs ] ) => {
+ const resolver = get( registry.namespaces, [ reducerKey, 'resolvers', selectorName ] );
+ if ( ! resolver || ! resolver.shouldInvalidate ) {
+ return;
+ }
+ resolversByArgs.forEach( ( value, args ) => {
+ // resolversByArgs is the map Map([ args ] => boolean) storing the cache resolution status for a given selector.
+ // If the value is false it means this resolver has finished its resolution which means we need to invalidate it,
+ // if it's true it means it's inflight and the invalidation is not necessary.
+ if ( value !== false || ! resolver.shouldInvalidate( action, ...args ) ) {
+ return;
+ }
+
+ // Trigger cache invalidation
+ registry.dispatch( 'core/data' ).invalidateResolution( reducerKey, selectorName, args );
+ } );
+ } );
+ next( action );
+};
+
+export default createResolversCacheMiddleware;
diff --git a/packages/data/src/store/actions.js b/packages/data/src/store/actions.js
index a386aae4eca617..e4d887ee3e58a8 100644
--- a/packages/data/src/store/actions.js
+++ b/packages/data/src/store/actions.js
@@ -35,3 +35,21 @@ export function finishResolution( reducerKey, selectorName, args ) {
args,
};
}
+
+/**
+ * Returns an action object used in signalling that we should invalidate the resolution cache.
+ *
+ * @param {string} reducerKey Registered store reducer key.
+ * @param {string} selectorName Name of selector for which resolver should be invalidated.
+ * @param {Array} args Arguments to associate for uniqueness.
+ *
+ * @return {Object} Action object.
+ */
+export function invalidateResolution( reducerKey, selectorName, args ) {
+ return {
+ type: 'INVALIDATE_RESOLUTION',
+ reducerKey,
+ selectorName,
+ args,
+ };
+}
diff --git a/packages/data/src/store/reducer.js b/packages/data/src/store/reducer.js
index 378535ce449727..69cd865766234f 100644
--- a/packages/data/src/store/reducer.js
+++ b/packages/data/src/store/reducer.js
@@ -25,11 +25,17 @@ const isResolved = flowRight( [
] )( ( state = new EquivalentKeyMap(), action ) => {
switch ( action.type ) {
case 'START_RESOLUTION':
- case 'FINISH_RESOLUTION':
+ case 'FINISH_RESOLUTION': {
const isStarting = action.type === 'START_RESOLUTION';
const nextState = new EquivalentKeyMap( state );
nextState.set( action.args, isStarting );
return nextState;
+ }
+ case 'INVALIDATE_RESOLUTION': {
+ const nextState = new EquivalentKeyMap( state );
+ nextState.delete( action.args );
+ return nextState;
+ }
}
return state;
diff --git a/packages/data/src/store/selectors.js b/packages/data/src/store/selectors.js
index 8f5c9d18de2fe9..b17594e6705a5d 100644
--- a/packages/data/src/store/selectors.js
+++ b/packages/data/src/store/selectors.js
@@ -69,3 +69,15 @@ export function hasFinishedResolution( state, reducerKey, selectorName, args = [
export function isResolving( state, reducerKey, selectorName, args = [] ) {
return getIsResolving( state, reducerKey, selectorName, args ) === true;
}
+
+/**
+ * Returns the list of the cached resolvers.
+ *
+ * @param {Object} state Data state.
+ * @param {string} reducerKey Registered store reducer key.
+ *
+ * @return {Object} Resolvers mapped by args and selectorName.
+ */
+export function getCachedResolvers( state, reducerKey ) {
+ return state.hasOwnProperty( reducerKey ) ? state[ reducerKey ] : {};
+}
diff --git a/packages/data/src/store/test/reducer.js b/packages/data/src/store/test/reducer.js
index 8b2c05e80e1f4a..186703c7738a23 100644
--- a/packages/data/src/store/test/reducer.js
+++ b/packages/data/src/store/test/reducer.js
@@ -45,6 +45,30 @@ describe( 'reducer', () => {
expect( state.test.getFoo.get( [] ) ).toBe( false );
} );
+ it( 'should remove invalidations', () => {
+ let state = reducer( undefined, {
+ type: 'START_RESOLUTION',
+ reducerKey: 'test',
+ selectorName: 'getFoo',
+ args: [],
+ } );
+ state = reducer( deepFreeze( state ), {
+ type: 'FINISH_RESOLUTION',
+ reducerKey: 'test',
+ selectorName: 'getFoo',
+ args: [],
+ } );
+ state = reducer( deepFreeze( state ), {
+ type: 'INVALIDATE_RESOLUTION',
+ reducerKey: 'test',
+ selectorName: 'getFoo',
+ args: [],
+ } );
+
+ // { test: { getFoo: EquivalentKeyMap( [] => undefined ) } }
+ expect( state.test.getFoo.get( [] ) ).toBe( undefined );
+ } );
+
it( 'different arguments should not conflict', () => {
const original = reducer( undefined, {
type: 'START_RESOLUTION',
diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js
index 2484bae4bd0b1e..7e6fd4a8d4990f 100644
--- a/packages/data/src/test/registry.js
+++ b/packages/data/src/test/registry.js
@@ -275,6 +275,37 @@ describe( 'createRegistry', () => {
return promise;
} );
+
+ it( 'should invalidate the resolver\'s resolution cache', async () => {
+ registry.registerStore( 'demo', {
+ reducer: ( state = 'NOTOK', action ) => {
+ return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK';
+ },
+ selectors: {
+ getValue: ( state ) => state,
+ },
+ resolvers: {
+ getValue: {
+ fulfill: () => Promise.resolve( { type: 'SET_OK' } ),
+ shouldInvalidate: ( action ) => action.type === 'INVALIDATE',
+ },
+ },
+ actions: {
+ invalidate: () => ( { type: 'INVALIDATE' } ),
+ },
+ } );
+
+ let promise = subscribeUntil( () => registry.select( 'demo' ).getValue() === 'OK' );
+ registry.select( 'demo' ).getValue(); // Triggers resolver switches to OK
+ await promise;
+
+ // Invalidate the cache
+ registry.dispatch( 'demo' ).invalidate();
+
+ promise = subscribeUntil( () => registry.select( 'demo' ).getValue() === 'NOTOK' );
+ registry.select( 'demo' ).getValue(); // Triggers the resolver again and switch to NOTOK
+ await promise;
+ } );
} );
describe( 'select', () => {
diff --git a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
index 698271cf89a07a..482d65cf62abb8 100644
--- a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
+++ b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { get, unescape as unescapeString, without, find, some, invoke } from 'lodash';
+import { get, unescape as unescapeString, without, find, some } from 'lodash';
/**
* WordPress dependencies
@@ -11,8 +11,6 @@ import { Component } from '@wordpress/element';
import { TreeSelect, withSpokenMessages, withFilters, Button } from '@wordpress/components';
import { withSelect, withDispatch } from '@wordpress/data';
import { withInstanceId, compose } from '@wordpress/compose';
-import apiFetch from '@wordpress/api-fetch';
-import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
@@ -26,7 +24,6 @@ const DEFAULT_QUERY = {
per_page: -1,
orderby: 'name',
order: 'asc',
- _fields: 'id,name,parent',
};
const MIN_TERMS_COUNT_FOR_FILTER = 8;
@@ -41,12 +38,7 @@ class HierarchicalTermSelector extends Component {
this.onAddTerm = this.onAddTerm.bind( this );
this.onToggleForm = this.onToggleForm.bind( this );
this.setFilterValue = this.setFilterValue.bind( this );
- this.sortBySelected = this.sortBySelected.bind( this );
this.state = {
- loading: true,
- availableTermsTree: [],
- availableTerms: [],
- adding: false,
formName: '',
formParent: '',
showForm: false,
@@ -56,13 +48,13 @@ class HierarchicalTermSelector extends Component {
}
onChange( event ) {
- const { onUpdateTerms, terms = [], taxonomy } = this.props;
+ const { onUpdateTerms, terms = [] } = this.props;
const termId = parseInt( event.target.value, 10 );
const hasTerm = terms.indexOf( termId ) !== -1;
const newTerms = hasTerm ?
without( terms, termId ) :
[ ...terms, termId ];
- onUpdateTerms( newTerms, taxonomy.rest_base );
+ onUpdateTerms( newTerms );
}
onChangeFormName( event ) {
@@ -89,9 +81,9 @@ class HierarchicalTermSelector extends Component {
onAddTerm( event ) {
event.preventDefault();
- const { onUpdateTerms, taxonomy, terms, slug } = this.props;
- const { formName, formParent, adding, availableTerms } = this.state;
- if ( formName === '' || adding ) {
+ const { onUpdateTerms, addTermToEditedPost, terms, availableTerms } = this.props;
+ const { formName, formParent } = this.state;
+ if ( formName === '' ) {
return;
}
@@ -100,7 +92,7 @@ class HierarchicalTermSelector extends Component {
if ( existingTerm ) {
// if the term we are adding exists but is not selected select it
if ( ! some( terms, ( term ) => term === existingTerm.id ) ) {
- onUpdateTerms( [ ...terms, existingTerm.id ], taxonomy.rest_base );
+ onUpdateTerms( [ ...terms, existingTerm.id ] );
}
this.setState( {
formName: '',
@@ -109,113 +101,14 @@ class HierarchicalTermSelector extends Component {
return;
}
- this.setState( {
- adding: true,
- } );
- this.addRequest = apiFetch( {
- path: `/wp/v2/${ taxonomy.rest_base }`,
- method: 'POST',
- data: {
- name: formName,
- parent: formParent ? formParent : undefined,
- },
+ addTermToEditedPost( {
+ name: formName,
+ parent: formParent ? formParent : undefined,
} );
- // Tries to create a term or fetch it if it already exists
- const findOrCreatePromise = this.addRequest
- .catch( ( error ) => {
- const errorCode = error.code;
- if ( errorCode === 'term_exists' ) {
- // search the new category created since last fetch
- this.addRequest = apiFetch( {
- path: addQueryArgs(
- `/wp/v2/${ taxonomy.rest_base }`,
- { ...DEFAULT_QUERY, parent: formParent || 0, search: formName }
- ),
- } );
- return this.addRequest
- .then( ( searchResult ) => {
- return this.findTerm( searchResult, formParent, formName );
- } );
- }
- return Promise.reject( error );
- } );
- findOrCreatePromise
- .then( ( term ) => {
- const hasTerm = !! find( this.state.availableTerms, ( availableTerm ) => availableTerm.id === term.id );
- const newAvailableTerms = hasTerm ? this.state.availableTerms : [ term, ...this.state.availableTerms ];
- const termAddedMessage = sprintf(
- _x( '%s added', 'term' ),
- get(
- this.props.taxonomy,
- [ 'data', 'labels', 'singular_name' ],
- slug === 'category' ? __( 'Category' ) : __( 'Term' )
- )
- );
- this.props.speak( termAddedMessage, 'assertive' );
- this.addRequest = null;
- this.setState( {
- adding: false,
- formName: '',
- formParent: '',
- availableTerms: newAvailableTerms,
- availableTermsTree: this.sortBySelected( buildTermsTree( newAvailableTerms ) ),
- } );
- onUpdateTerms( [ ...terms, term.id ], taxonomy.rest_base );
- }, ( xhr ) => {
- if ( xhr.statusText === 'abort' ) {
- return;
- }
- this.addRequest = null;
- this.setState( {
- adding: false,
- } );
- } );
- }
-
- componentDidMount() {
- this.fetchTerms();
- }
-
- componentWillUnmount() {
- invoke( this.fetchRequest, [ 'abort' ] );
- invoke( this.addRequest, [ 'abort' ] );
- }
-
- componentDidUpdate( prevProps ) {
- if ( this.props.taxonomy !== prevProps.taxonomy ) {
- this.fetchTerms();
- }
- }
-
- fetchTerms() {
- const { taxonomy } = this.props;
- if ( ! taxonomy ) {
- return;
- }
- this.fetchRequest = apiFetch( {
- path: addQueryArgs( `/wp/v2/${ taxonomy.rest_base }`, DEFAULT_QUERY ),
+ this.setState( {
+ formName: '',
+ formParent: '',
} );
- this.fetchRequest.then(
- ( terms ) => { // resolve
- const availableTermsTree = this.sortBySelected( buildTermsTree( terms ) );
-
- this.fetchRequest = null;
- this.setState( {
- loading: false,
- availableTermsTree,
- availableTerms: terms,
- } );
- },
- ( xhr ) => { // reject
- if ( xhr.statusText === 'abort' ) {
- return;
- }
- this.fetchRequest = null;
- this.setState( {
- loading: false,
- } );
- }
- );
}
sortBySelected( termsTree ) {
@@ -256,7 +149,7 @@ class HierarchicalTermSelector extends Component {
}
setFilterValue( event ) {
- const { availableTermsTree } = this.state;
+ const { availableTermsTree } = this.props;
const filterValue = event.target.value;
const filteredTermsTree = availableTermsTree.map( this.getFilterMatcher( filterValue ) ).filter( ( term ) => term );
const getResultCount = ( terms ) => {
@@ -269,16 +162,14 @@ class HierarchicalTermSelector extends Component {
}
return count;
};
- this.setState(
- {
- filterValue,
- filteredTermsTree,
- }
- );
+ this.setState( {
+ filterValue,
+ filteredTermsTree,
+ } );
const resultCount = getResultCount( filteredTermsTree );
const resultsFoundMessage = sprintf(
- _n( '%d result found.', '%d results found.', resultCount, 'term' ),
+ _n( '%d result found.', '%d results found.', resultCount ),
resultCount
);
this.props.debouncedSpeak( resultsFoundMessage, 'assertive' );
@@ -339,13 +230,21 @@ class HierarchicalTermSelector extends Component {
}
render() {
- const { slug, taxonomy, instanceId, hasCreateAction, hasAssignAction } = this.props;
+ const {
+ slug,
+ taxonomy,
+ instanceId,
+ hasCreateAction,
+ hasAssignAction,
+ availableTermsTree,
+ availableTerms,
+ } = this.props;
if ( ! hasAssignAction ) {
return null;
}
- const { availableTermsTree, availableTerms, filteredTermsTree, formName, formParent, loading, showForm, filterValue } = this.state;
+ const { filteredTermsTree, formName, formParent, isRequestingTerms, showForm, filterValue } = this.state;
const labelWithFallback = ( labelProperty, fallbackIsCategory, fallbackIsNotCategory ) => get(
taxonomy,
[ 'data', 'labels', labelProperty ],
@@ -386,7 +285,7 @@ class HierarchicalTermSelector extends Component {
slug === 'category' ? __( 'Categories' ) : __( 'Terms' )
)
);
- const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER;
+ const showFilter = availableTerms && ( availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER );
return [
showFilter &&