From f154dc7f2cdec6d6253132e388cf60a64692ee9a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 11 Jan 2024 17:31:52 +0000 Subject: [PATCH] Add: Bulk actions to dataviews with the new design. (#57255) * Add: Bulk actions to dataviews with the new design. * post rebase fixes. * Add missing secondary variant * Move bulk actions button position * Don't render bulk actions functionality when there are no bulk actions avaliable. * Fix button padding * Fix focus loss * fix flex shring * Fix focus issues * remove data items meanwhile removed from the selection * remove line accidently added * Fix vertical alignment post rebase. * Fix unrequired decodeEntities usage. * Apply feedback to the promises * lint fix * Feedback application * Labels object instead of function * Introduce removeTemplates action * fix css rebase issue * lint fixes. * remove labels * simplify logic condition * fix some classnames * multiple small changes of feedback * remove getItemTitle * Inspect Promise.allSettled result * typo * case fixing * update patterns actions * Added a selection mark * lint fixes * Style adjustments --------- Co-authored-by: James Koster --- packages/dataviews/src/bulk-actions.js | 187 ++++++++++++++++++ packages/dataviews/src/dataviews.js | 31 ++- packages/dataviews/src/item-actions.js | 6 +- packages/dataviews/src/style.scss | 56 +++++- packages/dataviews/src/view-table.js | 146 +++++++++++++- .../edit-site/src/components/actions/index.js | 21 +- .../dataviews-pattern-actions.js | 17 +- .../src/components/page-templates/index.js | 6 +- .../page-templates/template-actions.js | 114 +++++++---- packages/edit-site/src/store/actions.js | 56 +----- .../edit-site/src/store/private-actions.js | 103 ++++++++++ 11 files changed, 628 insertions(+), 115 deletions(-) create mode 100644 packages/dataviews/src/bulk-actions.js diff --git a/packages/dataviews/src/bulk-actions.js b/packages/dataviews/src/bulk-actions.js new file mode 100644 index 00000000000000..9fd9f628286e09 --- /dev/null +++ b/packages/dataviews/src/bulk-actions.js @@ -0,0 +1,187 @@ +/** + * WordPress dependencies + */ +import { + privateApis as componentsPrivateApis, + Button, + Modal, +} from '@wordpress/components'; +import { __, sprintf, _n } from '@wordpress/i18n'; +import { useMemo, useState, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; + +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuGroupV2: DropdownMenuGroup, + DropdownMenuItemV2: DropdownMenuItem, + DropdownMenuSeparatorV2: DropdownMenuSeparator, +} = unlock( componentsPrivateApis ); + +function ActionWithModal( { + action, + selectedItems, + setActionWithModal, + onMenuOpenChange, +} ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + const { RenderModal, hideModalHeader } = action; + const onCloseModal = useCallback( () => { + setActionWithModal( undefined ); + }, [ setActionWithModal ] ); + return ( + + onMenuOpenChange( false ) } + /> + + ); +} + +function BulkActionItem( { action, selectedItems, setActionWithModal } ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + + const shouldShowModal = !! action.RenderModal; + + return ( + { + if ( shouldShowModal ) { + setActionWithModal( action ); + } else { + await action.callback( eligibleItems ); + } + } } + suffix={ + eligibleItems.length > 0 ? eligibleItems.length : undefined + } + > + { action.label } + + ); +} + +function ActionsMenuGroup( { actions, selectedItems, setActionWithModal } ) { + return ( + <> + + { actions.map( ( action ) => ( + + ) ) } + + + + ); +} + +export default function BulkActions( { + data, + actions, + selection, + onSelectionChange, + getItemId, +} ) { + const bulkActions = useMemo( + () => actions.filter( ( action ) => action.supportsBulk ), + [ actions ] + ); + const areAllSelected = selection && selection.length === data.length; + const [ isMenuOpen, onMenuOpenChange ] = useState( false ); + const [ actionWithModal, setActionWithModal ] = useState(); + const selectedItems = useMemo( () => { + return data.filter( ( item ) => + selection.includes( getItemId( item ) ) + ); + }, [ selection, data, getItemId ] ); + + if ( bulkActions.length === 0 ) { + return null; + } + return ( + <> + + { selection.length + ? sprintf( + /* translators: %d: Number of items. */ + _n( + 'Edit %d item', + 'Edit %d items', + selection.length + ), + selection.length + ) + : __( 'Bulk edit' ) } + + } + > + + + { + onSelectionChange( data ); + } } + suffix={ data.length } + > + { __( 'Select all' ) } + + { + onSelectionChange( [] ); + } } + > + { __( 'Deselect' ) } + + + + { actionWithModal && ( + + ) } + + ); +} diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 61837e4f8fc964..64a70d46c7d127 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -5,7 +5,7 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, } from '@wordpress/components'; -import { useMemo, useState, useCallback } from '@wordpress/element'; +import { useMemo, useState, useCallback, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -14,7 +14,8 @@ import Pagination from './pagination'; import ViewActions from './view-actions'; import Filters from './filters'; import Search from './search'; -import { VIEW_LAYOUTS } from './constants'; +import { VIEW_LAYOUTS, LAYOUT_TABLE } from './constants'; +import BulkActions from './bulk-actions'; const defaultGetItemId = ( item ) => item.id; const defaultOnSelectionChange = () => {}; @@ -37,6 +38,23 @@ export default function DataViews( { } ) { const [ selection, setSelection ] = useState( [] ); + useEffect( () => { + if ( + selection.length > 0 && + selection.some( + ( id ) => ! data.some( ( item ) => item.id === id ) + ) + ) { + const newSelection = selection.filter( ( id ) => + data.some( ( item ) => item.id === id ) + ); + setSelection( newSelection ); + onSelectionChange( + data.filter( ( item ) => newSelection.includes( item.id ) ) + ); + } + }, [ selection, data, onSelectionChange ] ); + const onSetSelection = useCallback( ( items ) => { setSelection( items.map( ( item ) => item.id ) ); @@ -75,6 +93,15 @@ export default function DataViews( { onChangeView={ onChangeView } /> + { view.type === LAYOUT_TABLE && ( + + ) } setIsModalOpen( false ) } /> @@ -96,7 +96,7 @@ function ActionsDropdownMenuGroup( { actions, item } ) { action.callback( item ) } + onClick={ () => action.callback( [ item ] ) } /> ); } ) } @@ -157,7 +157,7 @@ export default function ItemActions( { item, actions, isCompact } ) { action.callback( item ) } + onClick={ () => action.callback( [ item ] ) } /> ); } ) } diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index 80630050b68efb..d934ea0df62d0a 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -18,6 +18,10 @@ } } +.dataviews-filters__view-actions.components-h-stack { + align-items: center; +} + .dataviews-filters-button { position: relative; } @@ -81,6 +85,14 @@ &[data-field-id="actions"] { text-align: right; } + + &.dataviews-view-table__checkbox-column { + padding-right: 0; + } + + .components-checkbox-control__input-container { + margin: $grid-unit-05; + } } tr { border-bottom: 1px solid $gray-100; @@ -109,8 +121,32 @@ } &:hover { - td { - background-color: #f8f8f8; + background-color: #f8f8f8; + } + + .components-checkbox-control__input { + opacity: 0; + + &:checked, + &:indeterminate, + &:focus { + opacity: 1; + } + } + + &:focus-within, + &:hover { + .components-checkbox-control__input { + opacity: 1; + } + } + + &.is-selected { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04); + color: $gray-700; + + &:hover { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); } } } @@ -373,7 +409,23 @@ padding: 0 $grid-unit-40; } +.dataviews-view-table-selection-checkbox label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .dataviews-filters__custom-menu-radio-item-prefix { display: block; width: 24px; } + +.dataviews-bulk-edit-button.components-button { + flex-shrink: 0; +} diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index dc76572e30494e..e59c4e001919c4 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -1,18 +1,19 @@ /** * External dependencies */ -import classNames from 'classnames'; +import classnames from 'classnames'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useAsyncList } from '@wordpress/compose'; import { unseen, funnel } from '@wordpress/icons'; import { Button, Icon, privateApis as componentsPrivateApis, + CheckboxControl, } from '@wordpress/components'; import { Children, @@ -306,6 +307,80 @@ function WithSeparators( { children } ) { ) ); } +function BulkSelectionCheckbox( { selection, onSelectionChange, data } ) { + const areAllSelected = selection.length === data.length; + return ( + { + if ( areAllSelected ) { + onSelectionChange( [] ); + } else { + onSelectionChange( data ); + } + } } + label={ areAllSelected ? __( 'Deselect all' ) : __( 'Select all' ) } + /> + ); +} + +function SingleSelectionCheckbox( { + selection, + onSelectionChange, + item, + data, + getItemId, + primaryField, +} ) { + const id = getItemId( item ); + const isSelected = selection.includes( id ); + let selectionLabel; + if ( primaryField?.getValue && item ) { + // eslint-disable-next-line @wordpress/valid-sprintf + selectionLabel = sprintf( + /* translators: %s: item title. */ + isSelected ? __( 'Deselect item: %s' ) : __( 'Select item: %s' ), + primaryField.getValue( { item } ) + ); + } else { + selectionLabel = isSelected + ? __( 'Select a new item' ) + : __( 'Deselect item' ); + } + return ( + { + if ( ! isSelected ) { + onSelectionChange( + data.filter( ( _item ) => { + const itemId = getItemId?.( _item ); + return ( + itemId === id || selection.includes( itemId ) + ); + } ) + ); + } else { + onSelectionChange( + data.filter( ( _item ) => { + const itemId = getItemId?.( _item ); + return ( + itemId !== id && selection.includes( itemId ) + ); + } ) + ); + } + } } + /> + ); +} + function ViewTable( { view, onChangeView, @@ -315,7 +390,10 @@ function ViewTable( { getItemId, isLoading = false, deferredRendering, + selection, + onSelectionChange, } ) { + const hasBulkActions = actions?.some( ( action ) => action.supportsBulk ); const headerMenuRefs = useRef( new Map() ); const headerMenuToFocusRef = useRef(); const [ nextHeaderMenuToFocus, setNextHeaderMenuToFocus ] = useState(); @@ -348,14 +426,16 @@ function ViewTable( { const visibleFields = fields.filter( ( field ) => ! view.hiddenFields.includes( field.id ) && - ! [ view.layout.mediaField, view.layout.primaryField ].includes( - field.id - ) + ! [ view.layout.mediaField ].includes( field.id ) ); const usedData = deferredRendering ? asyncData : data; const hasData = !! usedData?.length; const sortValues = { asc: 'ascending', desc: 'descending' }; + const primaryField = fields.find( + ( field ) => field.id === view.layout.primaryField + ); + return (
- + + { hasBulkActions && ( + + ) } { visibleFields.map( ( field, index ) => ( { hasData && - usedData.map( ( item ) => ( - + usedData.map( ( item, index ) => ( + + { hasBulkActions && ( + + ) } { visibleFields.map( ( field ) => (
+ +
+ +
{ + RenderModal: ( { items: posts, closeModal } ) => { + // Todo - handle multiple posts + const post = posts[ 0 ]; const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { deleteEntityRecord } = useDispatch( coreStore ); @@ -109,7 +111,9 @@ export function usePermanentlyDeletePostAction() { isEligible( { status } ) { return status === 'trash'; }, - async callback( post ) { + async callback( posts ) { + // Todo - handle multiple posts + const post = posts[ 0 ]; try { await deleteEntityRecord( 'postType', @@ -160,7 +164,9 @@ export function useRestorePostAction() { isEligible( { status } ) { return status === 'trash'; }, - async callback( post ) { + async callback( posts ) { + // Todo - handle multiple posts + const post = posts[ 0 ]; await editEntityRecord( 'postType', post.type, post.id, { status: 'draft', } ); @@ -211,7 +217,8 @@ export const viewPostAction = { isEligible( post ) { return post.status !== 'trash'; }, - callback( post ) { + callback( posts ) { + const post = posts[ 0 ]; document.location.href = post.link; }, }; @@ -225,7 +232,8 @@ export function useEditPostAction() { isEligible( { status } ) { return status !== 'trash'; }, - callback( post ) { + callback( posts ) { + const post = posts[ 0 ]; history.push( { postId: post.id, postType: post.type, @@ -250,7 +258,8 @@ export const postRevisionsAction = { post?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0; return lastRevisionId && revisionsCount > 1; }, - callback( post ) { + callback( posts ) { + const post = posts[ 0 ]; const href = addQueryArgs( 'revision.php', { revision: post?._links?.[ 'predecessor-version' ]?.[ 0 ]?.id, } ); diff --git a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js index bf5210beb49fbf..0c44c996ed373b 100644 --- a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js +++ b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js @@ -45,7 +45,7 @@ export const exportJSONaction = { id: 'export-pattern', label: __( 'Export as JSON' ), isEligible: ( item ) => item.type === PATTERN_TYPES.user, - callback: ( item ) => { + callback: ( [ item ] ) => { const json = { __file: item.type, title: item.title || item.name, @@ -71,7 +71,8 @@ export const renameAction = { const hasThemeFile = isTemplatePart && item.templatePart.has_theme_file; return isCustomPattern && ! hasThemeFile; }, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const [ title, setTitle ] = useState( () => item.title ); const { editEntityRecord, saveEditedEntityRecord } = useDispatch( coreStore ); @@ -160,7 +161,8 @@ export const deleteAction = { return canDeleteOrReset( item ) && ! hasThemeFile; }, hideModalHeader: true, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { __experimentalDeleteReusableBlock } = useDispatch( reusableBlocksStore ); const { createErrorNotice, createSuccessNotice } = @@ -224,7 +226,8 @@ export const resetAction = { return canDeleteOrReset( item ) && hasThemeFile; }, hideModalHeader: true, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { removeTemplate } = useDispatch( editSiteStore ); return ( @@ -254,7 +257,8 @@ export const duplicatePatternAction = { label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type !== TEMPLATE_PART_POST_TYPE, modalHeader: _x( 'Duplicate pattern', 'action label' ), - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { categoryId = PATTERN_DEFAULT_CATEGORY } = getQueryArgs( window.location.href ); @@ -288,7 +292,8 @@ export const duplicateTemplatePartAction = { label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type === TEMPLATE_PART_POST_TYPE, modalHeader: _x( 'Duplicate template part', 'action label' ), - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { createSuccessNotice } = useDispatch( noticesStore ); const { categoryId = PATTERN_DEFAULT_CATEGORY } = getQueryArgs( window.location.href diff --git a/packages/edit-site/src/components/page-templates/index.js b/packages/edit-site/src/components/page-templates/index.js index c0e0289311db6a..ddc48542ee1b7a 100644 --- a/packages/edit-site/src/components/page-templates/index.js +++ b/packages/edit-site/src/components/page-templates/index.js @@ -65,7 +65,9 @@ const { useHistory } = unlock( routerPrivateApis ); const EMPTY_ARRAY = []; const defaultConfigPerViewType = { - [ LAYOUT_TABLE ]: {}, + [ LAYOUT_TABLE ]: { + primaryField: 'title', + }, [ LAYOUT_GRID ]: { mediaField: 'preview', primaryField: 'title', @@ -84,7 +86,7 @@ const DEFAULT_VIEW = { // All fields are visible by default, so it's // better to keep track of the hidden ones. hiddenFields: [ 'preview' ], - layout: {}, + layout: defaultConfigPerViewType[ LAYOUT_TABLE ], filters: [], }; diff --git a/packages/edit-site/src/components/page-templates/template-actions.js b/packages/edit-site/src/components/page-templates/template-actions.js index 9f5897e31fb93e..7029d464ca8671 100644 --- a/packages/edit-site/src/components/page-templates/template-actions.js +++ b/packages/edit-site/src/components/page-templates/template-actions.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { backup, trash } from '@wordpress/icons'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, sprintf, _n } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { useMemo, useState } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; @@ -19,6 +19,7 @@ import { /** * Internal dependencies */ +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import isTemplateRevertable from '../../utils/is-template-revertable'; import isTemplateRemovable from '../../utils/is-template-removable'; @@ -32,39 +33,64 @@ export function useResetTemplateAction() { return useMemo( () => ( { id: 'reset-template', - label: __( 'Reset template' ), + label: __( 'Reset' ), isPrimary: true, icon: backup, isEligible: isTemplateRevertable, - async callback( template ) { + supportsBulk: true, + async callback( templates ) { try { - await revertTemplate( template, { allowUndo: false } ); - await saveEditedEntityRecord( - 'postType', - template.type, - template.id - ); + for ( const template of templates ) { + await revertTemplate( template, { + allowUndo: false, + } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + } createSuccessNotice( - sprintf( - /* translators: The template/part's name. */ - __( '"%s" reverted.' ), - decodeEntities( template.title.rendered ) - ), + templates.length > 1 + ? sprintf( + /* translators: The number of items. */ + __( '%s items reverted.' ), + templates.length + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reverted.' ), + decodeEntities( + templates[ 0 ].title.rendered + ) + ), { type: 'snackbar', id: 'edit-site-template-reverted', } ); } catch ( error ) { - const fallbackErrorMessage = - template.type === TEMPLATE_POST_TYPE - ? __( - 'An error occurred while reverting the template.' - ) - : __( - 'An error occurred while reverting the template part.' - ); + let fallbackErrorMessage; + if ( templates[ 0 ].type === TEMPLATE_POST_TYPE ) { + fallbackErrorMessage = + templates.length === 1 + ? __( + 'An error occurred while reverting the template.' + ) + : __( + 'An error occurred while reverting the templates.' + ); + } else { + fallbackErrorMessage = + templates.length === 1 + ? __( + 'An error occurred while reverting the template part.' + ) + : __( + 'An error occurred while reverting the template parts.' + ); + } const errorMessage = error.message && error.code !== 'unknown_error' ? error.message @@ -85,21 +111,34 @@ export function useResetTemplateAction() { export const deleteTemplateAction = { id: 'delete-template', - label: __( 'Delete template' ), + label: __( 'Delete' ), isPrimary: true, icon: trash, isEligible: isTemplateRemovable, + supportsBulk: true, hideModalHeader: true, - RenderModal: ( { item: template, closeModal } ) => { - const { removeTemplate } = useDispatch( editSiteStore ); + RenderModal: ( { items: templates, closeModal, onPerform } ) => { + const { removeTemplates } = unlock( useDispatch( editSiteStore ) ); return ( - { sprintf( - // translators: %s: The template or template part's title. - __( 'Are you sure you want to delete "%s"?' ), - decodeEntities( template.title.rendered ) - ) } + { templates.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + templates.length + ), + templates.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + decodeEntities( + templates?.[ 0 ]?.title?.rendered + ) + ) } @@ -126,7 +169,8 @@ export const renameTemplateAction = { label: __( 'Rename' ), isEligible: ( template ) => isTemplateRemovable( template ) && template.is_custom, - RenderModal: ( { item: template, closeModal } ) => { + RenderModal: ( { items: templates, closeModal } ) => { + const template = templates[ 0 ]; const title = decodeEntities( template.title.rendered ); const [ editedTitle, setEditedTitle ] = useState( title ); const { diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index 5a8adad8e198b8..e7f2671784e1d0 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -5,7 +5,7 @@ import apiFetch from '@wordpress/api-fetch'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; import { addQueryArgs } from '@wordpress/url'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; @@ -13,7 +13,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as editorStore } from '@wordpress/editor'; import { speak } from '@wordpress/a11y'; import { store as preferencesStore } from '@wordpress/preferences'; -import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -25,6 +24,8 @@ import { TEMPLATE_PART_POST_TYPE, NAVIGATION_POST_TYPE, } from '../utils/constants'; +import { removeTemplates } from './private-actions'; + /** * Dispatches an action that toggles a feature flag. * @@ -133,54 +134,9 @@ export const addTemplate = * * @param {Object} template The template object. */ -export const removeTemplate = - ( template ) => - async ( { registry } ) => { - try { - await registry - .dispatch( coreStore ) - .deleteEntityRecord( 'postType', template.type, template.id, { - force: true, - } ); - - const lastError = registry - .select( coreStore ) - .getLastEntityDeleteError( - 'postType', - template.type, - template.id - ); - - if ( lastError ) { - throw lastError; - } - - // Depending on how the entity was retrieved it's title might be - // an object or simple string. - const templateTitle = - typeof template.title === 'string' - ? template.title - : template.title?.rendered; - - registry.dispatch( noticesStore ).createSuccessNotice( - sprintf( - /* translators: The template/part's name. */ - __( '"%s" deleted.' ), - decodeEntities( templateTitle ) - ), - { type: 'snackbar', id: 'site-editor-template-deleted-success' } - ); - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'An error occurred while deleting the template.' ); - - registry - .dispatch( noticesStore ) - .createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - }; +export const removeTemplate = ( template ) => { + return removeTemplates( [ template ] ); +}; /** * Action that sets a template part. diff --git a/packages/edit-site/src/store/private-actions.js b/packages/edit-site/src/store/private-actions.js index 7354f7b9b8843a..71f35dc66399ee 100644 --- a/packages/edit-site/src/store/private-actions.js +++ b/packages/edit-site/src/store/private-actions.js @@ -4,6 +4,10 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import { __, sprintf } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Action that switches the canvas mode. @@ -49,3 +53,102 @@ export const setEditorCanvasContainerView = view, } ); }; + +/** + * Action that removes an array of templates. + * + * @param {Array} templates An array of template objects to remove. + */ +export const removeTemplates = + ( templates ) => + async ( { registry } ) => { + const promiseResult = await Promise.allSettled( + templates.map( ( template ) => { + return registry + .dispatch( coreStore ) + .deleteEntityRecord( + 'postType', + template.type, + template.id, + { force: true }, + { throwOnError: true } + ); + } ) + ); + + // If all the promises were fulfilled with sucess. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + + if ( templates.length === 1 ) { + // Depending on how the entity was retrieved its title might be + // an object or simple string. + const templateTitle = + typeof templates[ 0 ].title === 'string' + ? templates[ 0 ].title + : templates[ 0 ].title?.rendered; + successMessage = sprintf( + /* translators: The template/part's name. */ + __( '"%s" deleted.' ), + decodeEntities( templateTitle ) + ); + } else { + successMessage = __( 'Templates deleted.' ); + } + + registry + .dispatch( noticesStore ) + .createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'site-editor-template-deleted-success', + } ); + } else { + // If there was at lease one failure. + let errorMessage; + // If we were trying to delete a single template. + if ( promiseResult.length === 1 ) { + if ( promiseResult[ 0 ].reason?.message ) { + errorMessage = promiseResult[ 0 ].reason.message; + } else { + errorMessage = __( + 'An error occurred while deleting the template.' + ); + } + // If we were trying to delete a multiple templates + } else { + const errorMessages = new Set(); + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + if ( failedPromise.reason?.message ) { + errorMessages.add( failedPromise.reason.message ); + } + } + if ( errorMessages.size === 0 ) { + errorMessage = __( + 'An error occurred while deleting the templates.' + ); + } else if ( errorMessages.size === 1 ) { + errorMessage = sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while deleting the templates: %s' + ), + [ ...errorMessages ][ 0 ] + ); + } else { + errorMessage = sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while deleting the templates: %s' + ), + [ ...errorMessages ].join( ',' ) + ); + } + } + registry + .dispatch( noticesStore ) + .createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + };