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 1ec9c278a90880..f17c9a2bd7d2d8 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 ); } } } />