diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md
index 3372d7eeab7283..9ed5dcca151197 100644
--- a/docs/designers-developers/developers/data/data-core.md
+++ b/docs/designers-developers/developers/data/data-core.md
@@ -214,6 +214,21 @@ _Returns_
- `?Array`: Records.
+# **getLastEntityDeleteError**
+
+Returns the specified entity record's last delete error.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's save error.
+
# **getLastEntitySaveError**
Returns the specified entity record's last save error.
@@ -407,6 +422,21 @@ _Returns_
- `boolean`: Whether the entity record is autosaving or not.
+# **isDeletingEntityRecord**
+
+Returns true if the specified entity record is deleting, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `boolean`: Whether the entity record is deleting or not.
+
# **isPreviewEmbedFallback**
Determines if the returned preview is an oEmbed link fallback.
@@ -471,6 +501,17 @@ _Returns_
- `Object`: Action object.
+# **deleteEntityRecord**
+
+Action triggered to delete an entity record.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the deleted entity.
+- _name_ `string`: Name of the deleted entity.
+- _recordId_ `string`: Record ID of the deleted entity.
+- _query_ `?Object`: Special query parameters for the DELETE API call.
+
# **editEntityRecord**
Returns an action object that triggers an
diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md
index cfabdcf6918764..9d425db1b2abe1 100644
--- a/packages/core-data/CHANGELOG.md
+++ b/packages/core-data/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+### New Feature
+
+- The `deleteEntityRecord` and `removeItems` actions have been added.
+- The `isDeletingEntityRecord` and `getLastEntityDeleteError` selectors have been added.
+- A `delete` helper is created for every registered entity.
+
## 2.3.0 (2019-05-21)
### New features
diff --git a/packages/core-data/README.md b/packages/core-data/README.md
index 6b8c3481de0c24..541cb629edb013 100644
--- a/packages/core-data/README.md
+++ b/packages/core-data/README.md
@@ -54,6 +54,17 @@ _Returns_
- `Object`: Action object.
+# **deleteEntityRecord**
+
+Action triggered to delete an entity record.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the deleted entity.
+- _name_ `string`: Name of the deleted entity.
+- _recordId_ `string`: Record ID of the deleted entity.
+- _query_ `?Object`: Special query parameters for the DELETE API call.
+
# **editEntityRecord**
Returns an action object that triggers an
@@ -440,6 +451,21 @@ _Returns_
- `?Array`: Records.
+# **getLastEntityDeleteError**
+
+Returns the specified entity record's last delete error.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's save error.
+
# **getLastEntitySaveError**
Returns the specified entity record's last save error.
@@ -633,6 +659,21 @@ _Returns_
- `boolean`: Whether the entity record is autosaving or not.
+# **isDeletingEntityRecord**
+
+Returns true if the specified entity record is deleting, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `boolean`: Whether the entity record is deleting or not.
+
# **isPreviewEmbedFallback**
Determines if the returned preview is an oEmbed link fallback.
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index 10547c966db5da..2b5d07d6bc4022 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -3,10 +3,15 @@
*/
import { castArray, get, isEqual, find } from 'lodash';
+/**
+ * WordPress dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+
/**
* Internal dependencies
*/
-import { receiveItems, receiveQueriedItems } from './queried-data';
+import { receiveItems, removeItems, receiveQueriedItems } from './queried-data';
import { getKindEntities, DEFAULT_ENTITY_KEY } from './entities';
import { select, apiFetch } from './controls';
@@ -139,6 +144,58 @@ export function receiveEmbedPreview( url, preview ) {
};
}
+/**
+ * Action triggered to delete an entity record.
+ *
+ * @param {string} kind Kind of the deleted entity.
+ * @param {string} name Name of the deleted entity.
+ * @param {string} recordId Record ID of the deleted entity.
+ * @param {?Object} query Special query parameters for the DELETE API call.
+ */
+export function* deleteEntityRecord( kind, name, recordId, query ) {
+ const entities = yield getKindEntities( kind );
+ const entity = find( entities, { kind, name } );
+ let error;
+ let deletedRecord = false;
+ if ( ! entity ) {
+ return;
+ }
+
+ yield {
+ type: 'DELETE_ENTITY_RECORD_START',
+ kind,
+ name,
+ recordId,
+ };
+
+ try {
+ let path = `${ entity.baseURL }/${ recordId }`;
+
+ if ( query ) {
+ path = addQueryArgs( path, query );
+ }
+
+ deletedRecord = yield apiFetch( {
+ path,
+ method: 'DELETE',
+ } );
+
+ yield removeItems( kind, name, recordId, true );
+ } catch ( _error ) {
+ error = _error;
+ }
+
+ yield {
+ type: 'DELETE_ENTITY_RECORD_FINISH',
+ kind,
+ name,
+ recordId,
+ error,
+ };
+
+ return deletedRecord;
+}
+
/**
* Returns an action object that triggers an
* edit to an entity record.
diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js
index 4cafd782d2b4ef..b1ab2f83b01fad 100644
--- a/packages/core-data/src/index.js
+++ b/packages/core-data/src/index.js
@@ -49,6 +49,8 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => {
const { kind, name } = entity;
result[ getMethodName( kind, name, 'save' ) ] = ( key ) =>
actions.saveEntityRecord( kind, name, key );
+ result[ getMethodName( kind, name, 'delete' ) ] = ( key, query ) =>
+ actions.deleteEntityRecord( kind, name, key, query );
return result;
}, {} );
diff --git a/packages/core-data/src/queried-data/actions.js b/packages/core-data/src/queried-data/actions.js
index 87add69ad0d51c..d44d7876dd1121 100644
--- a/packages/core-data/src/queried-data/actions.js
+++ b/packages/core-data/src/queried-data/actions.js
@@ -17,6 +17,26 @@ export function receiveItems( items ) {
};
}
+/**
+ * Returns an action object used in signalling that entity records have been
+ * deleted and they need to be removed from entities state.
+ *
+ * @param {string} kind Kind of the removed entities.
+ * @param {string} name Name of the removed entities.
+ * @param {Array|number} records Record IDs of the removed entities.
+ * @param {boolean} invalidateCache Controls whether we want to invalidate the cache.
+ * @return {Object} Action object.
+ */
+export function removeItems( kind, name, records, invalidateCache = false ) {
+ return {
+ type: 'REMOVE_ITEMS',
+ itemIds: castArray( records ),
+ kind,
+ name,
+ invalidateCache,
+ };
+}
+
/**
* Returns an action object used in signalling that queried data has been
* received.
diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js
index 8c349a8b349bd0..c84b210cf547c7 100644
--- a/packages/core-data/src/queried-data/reducer.js
+++ b/packages/core-data/src/queried-data/reducer.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { map, flowRight } from 'lodash';
+import { map, flowRight, omit, forEach, filter } from 'lodash';
/**
* WordPress dependencies
@@ -86,8 +86,10 @@ function items( state = {}, action ) {
return accumulator;
}, {} ),
};
+ case 'REMOVE_ITEMS':
+ const newState = omit( state, action.itemIds );
+ return newState;
}
-
return state;
}
@@ -100,7 +102,7 @@ function items( state = {}, action ) {
*
* @return {Object} Next state.
*/
-const queries = flowRight( [
+const receiveQueries = flowRight( [
// Limit to matching action type so we don't attempt to replace action on
// an unhandled action.
ifMatchingAction( ( action ) => 'query' in action ),
@@ -138,6 +140,35 @@ const queries = flowRight( [
);
} );
+/**
+ * Reducer tracking queries state.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Next state.
+ */
+const queries = ( state = {}, action ) => {
+ switch ( action.type ) {
+ case 'RECEIVE_ITEMS':
+ return receiveQueries( state, action );
+ case 'REMOVE_ITEMS':
+ const newState = { ...state };
+ const removedItems = action.itemIds.reduce( ( result, itemId ) => {
+ result[ itemId ] = true;
+ return result;
+ }, {} );
+ forEach( newState, ( queryItems, key ) => {
+ newState[ key ] = filter( queryItems, ( queryId ) => {
+ return ! removedItems[ queryId ];
+ } );
+ } );
+ return newState;
+ default:
+ return state;
+ }
+};
+
export default combineReducers( {
items,
queries,
diff --git a/packages/core-data/src/queried-data/test/actions.js b/packages/core-data/src/queried-data/test/actions.js
new file mode 100644
index 00000000000000..0dc78152701a5b
--- /dev/null
+++ b/packages/core-data/src/queried-data/test/actions.js
@@ -0,0 +1,17 @@
+/**
+ * Internal dependencies
+ */
+import { removeItems } from '../actions';
+
+describe( 'removeItems', () => {
+ it( 'builds an action object', () => {
+ const postIds = [ 1, 2, 3 ];
+ expect( removeItems( 'postType', 'post', postIds ) ).toEqual( {
+ type: 'REMOVE_ITEMS',
+ itemIds: postIds,
+ kind: 'postType',
+ name: 'post',
+ invalidateCache: false,
+ } );
+ } );
+} );
diff --git a/packages/core-data/src/queried-data/test/reducer.js b/packages/core-data/src/queried-data/test/reducer.js
index 4e9f678a5a8065..c4ba348b7212df 100644
--- a/packages/core-data/src/queried-data/test/reducer.js
+++ b/packages/core-data/src/queried-data/test/reducer.js
@@ -7,6 +7,7 @@ import deepFreeze from 'deep-freeze';
* Internal dependencies
*/
import reducer, { getMergedItemIds } from '../reducer';
+import { removeItems } from '../actions';
describe( 'getMergedItemIds', () => {
it( 'should receive a page', () => {
@@ -113,4 +114,34 @@ describe( 'reducer', () => {
queries: {},
} );
} );
+
+ it( 'deletes an item', () => {
+ const kind = 'root';
+ const name = 'menu';
+ const original = deepFreeze( {
+ items: {
+ 1: { id: 1, name: 'abc' },
+ 2: { id: 2, name: 'def' },
+ 3: { id: 3, name: 'ghi' },
+ 4: { id: 4, name: 'klm' },
+ },
+ queries: {
+ '': [ 1, 2, 3, 4 ],
+ 's=a': [ 1, 3 ],
+ },
+ } );
+ const state = reducer( original, removeItems( kind, name, 3 ) );
+
+ expect( state ).toEqual( {
+ items: {
+ 1: { id: 1, name: 'abc' },
+ 2: { id: 2, name: 'def' },
+ 4: { id: 4, name: 'klm' },
+ },
+ queries: {
+ '': [ 1, 2, 4 ],
+ 's=a': [ 1 ],
+ },
+ } );
+ } );
} );
diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js
index b324010b787fe2..8238c42380da9f 100644
--- a/packages/core-data/src/reducer.js
+++ b/packages/core-data/src/reducer.js
@@ -276,6 +276,24 @@ function entity( entityConfig ) {
return state;
},
+
+ deleting: ( state = {}, action ) => {
+ switch ( action.type ) {
+ case 'DELETE_ENTITY_RECORD_START':
+ case 'DELETE_ENTITY_RECORD_FINISH':
+ return {
+ ...state,
+ [ action.recordId ]: {
+ pending:
+ action.type ===
+ 'DELETE_ENTITY_RECORD_START',
+ error: action.error,
+ },
+ };
+ }
+
+ return state;
+ },
} )
);
}
diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js
index a1c8ddaf33f63e..96a87f9a79a7b6 100644
--- a/packages/core-data/src/resolvers.js
+++ b/packages/core-data/src/resolvers.js
@@ -102,7 +102,7 @@ export function* getEntityRecords( kind, name, query = {} ) {
getEntityRecords.shouldInvalidate = ( action, kind, name ) => {
return (
- action.type === 'RECEIVE_ITEMS' &&
+ ( action.type === 'RECEIVE_ITEMS' || action.type === 'REMOVE_ITEMS' ) &&
action.invalidateCache &&
kind === action.kind &&
name === action.name
diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js
index 815ff190315478..adb3bf82e0d824 100644
--- a/packages/core-data/src/selectors.js
+++ b/packages/core-data/src/selectors.js
@@ -366,6 +366,24 @@ export function isSavingEntityRecord( state, kind, name, recordId ) {
);
}
+/**
+ * Returns true if the specified entity record is deleting, and false otherwise.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {boolean} Whether the entity record is deleting or not.
+ */
+export function isDeletingEntityRecord( state, kind, name, recordId ) {
+ return get(
+ state.entities.data,
+ [ kind, name, 'deleting', recordId, 'pending' ],
+ false
+ );
+}
+
/**
* Returns the specified entity record's last save error.
*
@@ -386,6 +404,26 @@ export function getLastEntitySaveError( state, kind, name, recordId ) {
] );
}
+/**
+ * Returns the specified entity record's last delete error.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} The entity record's save error.
+ */
+export function getLastEntityDeleteError( state, kind, name, recordId ) {
+ return get( state.entities.data, [
+ kind,
+ name,
+ 'deleting',
+ recordId,
+ 'error',
+ ] );
+}
+
/**
* Returns the current undo offset for the
* entity records edits history. The offset
diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js
index 4ed8f389ca1adf..9413349f27ba26 100644
--- a/packages/core-data/src/test/actions.js
+++ b/packages/core-data/src/test/actions.js
@@ -4,6 +4,7 @@
import {
editEntityRecord,
saveEntityRecord,
+ deleteEntityRecord,
receiveEntityRecords,
receiveUserPermission,
receiveAutosaves,
@@ -30,6 +31,42 @@ describe( 'editEntityRecord', () => {
} );
} );
+describe( 'deleteEntityRecord', () => {
+ it( 'triggers a DELETE request for an existing record', async () => {
+ const post = 10;
+ const entities = [
+ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' },
+ ];
+ const fulfillment = deleteEntityRecord( 'postType', 'post', post );
+
+ // Trigger generator
+ fulfillment.next();
+
+ // Start
+ expect( fulfillment.next( entities ).value.type ).toBe(
+ 'DELETE_ENTITY_RECORD_START'
+ );
+
+ // delete api call
+ const { value: apiFetchAction } = fulfillment.next();
+ expect( apiFetchAction.request ).toEqual( {
+ path: '/wp/v2/posts/10',
+ method: 'DELETE',
+ } );
+
+ expect( fulfillment.next().value.type ).toBe( 'REMOVE_ITEMS' );
+
+ expect( fulfillment.next().value.type ).toBe(
+ 'DELETE_ENTITY_RECORD_FINISH'
+ );
+
+ expect( fulfillment.next() ).toMatchObject( {
+ done: true,
+ value: undefined,
+ } );
+ } );
+} );
+
describe( 'saveEntityRecord', () => {
it( 'triggers a POST request for a new record', async () => {
const post = { title: 'new post' };
diff --git a/packages/edit-navigation/src/components/delete-menu-button/index.js b/packages/edit-navigation/src/components/delete-menu-button/index.js
index 118b1cc972fd4e..9cf1799ebfaf71 100644
--- a/packages/edit-navigation/src/components/delete-menu-button/index.js
+++ b/packages/edit-navigation/src/components/delete-menu-button/index.js
@@ -1,20 +1,10 @@
/**
* WordPress dependencies
*/
-import apiFetch from '@wordpress/api-fetch';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-export default function DeleteMenuButton( { menuId, onDelete } ) {
- const deleteMenu = async ( recordId ) => {
- const path = `/__experimental/menus/${ recordId }?force=true`;
- const deletedRecord = await apiFetch( {
- path,
- method: 'DELETE',
- } );
- return deletedRecord.previous;
- };
-
+export default function DeleteMenuButton( { onDelete } ) {
const askToDelete = async () => {
if (
// eslint-disable-next-line no-alert
@@ -22,8 +12,7 @@ export default function DeleteMenuButton( { menuId, onDelete } ) {
__( 'Are you sure you want to delete this navigation?' )
)
) {
- const deletedMenu = await deleteMenu( menuId );
- onDelete( deletedMenu.id );
+ onDelete();
}
};
diff --git a/packages/edit-navigation/src/components/menus-editor/index.js b/packages/edit-navigation/src/components/menus-editor/index.js
index 2f2b5c2407c803..7b22b5a295c710 100644
--- a/packages/edit-navigation/src/components/menus-editor/index.js
+++ b/packages/edit-navigation/src/components/menus-editor/index.js
@@ -1,8 +1,13 @@
+/**
+ * External dependencies
+ */
+import { uniqueId } from 'lodash';
+
/**
* WordPress dependencies
*/
-import { useSelect } from '@wordpress/data';
-import { useState, useEffect } from '@wordpress/element';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useState, useEffect, useRef } from '@wordpress/element';
import {
Button,
Card,
@@ -11,6 +16,7 @@ import {
SelectControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
+const { DOMParser } = window;
/**
* Internal dependencies
@@ -19,33 +25,64 @@ import CreateMenuArea from './create-menu-area';
import NavigationEditor from '../navigation-editor';
export default function MenusEditor( { blockEditorSettings } ) {
- const { menus, hasLoadedMenus } = useSelect( ( select ) => {
- const { getMenus, hasFinishedResolution } = select( 'core' );
- const query = { per_page: -1 };
- return {
- menus: getMenus( query ),
- hasLoadedMenus: hasFinishedResolution( 'getMenus', [ query ] ),
- };
- }, [] );
-
+ const [ menuId, setMenuId ] = useState();
+ const [ showCreateMenuPanel, setShowCreateMenuPanel ] = useState( false );
const [ hasCompletedFirstLoad, setHasCompletedFirstLoad ] = useState(
false
);
+ const noticeId = useRef();
+
+ const { menus, hasLoadedMenus, menuDeleteError } = useSelect(
+ ( select ) => {
+ const {
+ getMenus,
+ hasFinishedResolution,
+ getLastEntityDeleteError,
+ } = select( 'core' );
+ const query = { per_page: -1 };
+ return {
+ menus: getMenus( query ),
+ hasLoadedMenus: hasFinishedResolution( 'getMenus', [ query ] ),
+ menuDeleteError: getLastEntityDeleteError(
+ 'root',
+ 'menu',
+ menuId
+ ),
+ };
+ },
+ [ menuId ]
+ );
+
+ const { deleteMenu } = useDispatch( 'core' );
+ const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
+
useEffect( () => {
if ( ! hasCompletedFirstLoad && hasLoadedMenus ) {
setHasCompletedFirstLoad( true );
}
}, [ hasLoadedMenus ] );
- const [ menuId, setMenuId ] = useState();
- const [ stateMenus, setStateMenus ] = useState();
- const [ showCreateMenuPanel, setShowCreateMenuPanel ] = useState( false );
+ // Handle REST API Error messages.
+ useEffect( () => {
+ if ( menuDeleteError ) {
+ // Error messages from the REST API often contain HTML.
+ // createErrorNotice does not support HTML in error text, so first
+ // strip HTML out using DOMParser.
+ const document = new DOMParser().parseFromString(
+ menuDeleteError.message,
+ 'text/html'
+ );
+ const errorText = document.body.textContent || '';
+ noticeId.current = uniqueId(
+ 'navigation-editor/menu-editor/edit-navigation-delete-menu-error'
+ );
+ createErrorNotice( errorText, { id: noticeId.current } );
+ }
+ }, [ menuDeleteError ] );
useEffect( () => {
if ( menus?.length ) {
- setStateMenus( menus );
-
// Only set menuId if it's currently unset.
if ( ! menuId ) {
setMenuId( menus[ 0 ].id );
@@ -57,7 +94,7 @@ export default function MenusEditor( { blockEditorSettings } ) {
return ;
}
- const hasMenus = !! stateMenus?.length;
+ const hasMenus = !! menus?.length;
const isCreateMenuPanelVisible =
hasCompletedFirstLoad && ( ! hasMenus || showCreateMenuPanel );
@@ -75,7 +112,7 @@ export default function MenusEditor( { blockEditorSettings } ) {
( {
+ options={ menus?.map( ( menu ) => ( {
value: menu.id,
label: menu.name,
} ) ) }
@@ -96,7 +133,7 @@ export default function MenusEditor( { blockEditorSettings } ) {
{ isCreateMenuPanelVisible && (
{
- const newStateMenus = stateMenus.filter( ( menu ) => {
- return menu.id !== deletedMenu;
+ onDeleteMenu={ async () => {
+ removeNotice( noticeId.current );
+ const deletedMenu = await deleteMenu( menuId, {
+ force: 'true',
} );
- setStateMenus( newStateMenus );
- if ( newStateMenus.length ) {
- setMenuId( newStateMenus[ 0 ].id );
- } else {
- setMenuId();
+ if ( deletedMenu ) {
+ setMenuId( false );
}
} }
/>