diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js new file mode 100644 index 00000000000000..1b9bb5f2ffed24 --- /dev/null +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -0,0 +1,224 @@ +/** + * WordPress dependencies + */ +import { + ToolbarButton, + Toolbar, + ToolbarGroup, + __unstableMotion as motion, + __unstableAnimatePresence as AnimatePresence, +} from '@wordpress/components'; +import { useMemo, useState, useRef } from '@wordpress/element'; +import { _n, sprintf, __ } from '@wordpress/i18n'; +import { closeSmall } from '@wordpress/icons'; +import { useReducedMotion } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { ActionWithModal } from './item-actions'; + +const SNACKBAR_VARIANTS = { + init: { + bottom: -48, + }, + open: { + bottom: 24, + transition: { + bottom: { type: 'tween', duration: 0.2, ease: [ 0, 0, 0.2, 1 ] }, + }, + }, + exit: { + opacity: 0, + bottom: 24, + transition: { + opacity: { type: 'tween', duration: 0.2, ease: [ 0, 0, 0.2, 1 ] }, + }, + }, +}; + +function ActionTrigger( { action, onClick, items, isBusy } ) { + const isDisabled = useMemo( () => { + return isBusy || items.some( ( item ) => ! action.isEligible( item ) ); + }, [ action, items, isBusy ] ); + return ( + + ); +} + +const EMPTY_ARRAY = []; + +function renderToolbarContent( + selection, + actionsToShow, + selectedItems, + actionInProgress, + setActionInProgress, + setSelection +) { + return ( + <> + + + { selection.length === 1 + ? __( '1 item selected' ) + : sprintf( + // translators: %s: Total number of selected items. + _n( + '%s item selected', + '%s items selected', + selection.length + ), + selection.length + ) } + + + + { actionsToShow.map( ( action ) => { + if ( !! action.RenderModal ) { + return ( + { + setActionInProgress( action.id ); + } } + onActionPerformed={ () => { + setActionInProgress( null ); + } } + /> + ); + } + return ( + { + setActionInProgress( action.id ); + action.callback( selectedItems, () => { + setActionInProgress( action.id ); + } ); + } } + isBusy={ actionInProgress === action.id } + /> + ); + } ) } + + + { + setSelection( EMPTY_ARRAY ); + } } + /> + + > + ); +} + +function ToolbarContent( { + selection, + actionsToShow, + selectedItems, + setSelection, +} ) { + const [ actionInProgress, setActionInProgress ] = useState( null ); + const buttons = useRef( null ); + if ( ! actionInProgress ) { + if ( buttons.current ) { + buttons.current = null; + } + return renderToolbarContent( + selection, + actionsToShow, + selectedItems, + actionInProgress, + setActionInProgress, + setSelection + ); + } else if ( ! buttons.current ) { + buttons.current = renderToolbarContent( + selection, + actionsToShow, + selectedItems, + actionInProgress, + setActionInProgress, + setSelection + ); + } + return buttons.current; +} + +export default function BulkActionsToolbar( { + data, + selection, + actions = EMPTY_ARRAY, + setSelection, + getItemId, +} ) { + const isReducedMotion = useReducedMotion(); + const selectedItems = useMemo( () => { + return data.filter( ( item ) => + selection.includes( getItemId( item ) ) + ); + }, [ selection, data, getItemId ] ); + + const actionsToShow = useMemo( + () => + actions.filter( ( action ) => { + return ( + action.supportsBulk && + action.icon && + selectedItems.some( ( item ) => action.isEligible( item ) ) + ); + } ), + [ actions, selectedItems ] + ); + + if ( + ( selection && selection.length === 0 ) || + actionsToShow.length === 0 + ) { + return null; + } + + return ( + + + + + + + + + + ); +} diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index d67115deb3d6b2..f98e2a6352f064 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -14,6 +14,7 @@ import Search from './search'; import { VIEW_LAYOUTS, LAYOUT_TABLE, LAYOUT_GRID } from './constants'; import BulkActions from './bulk-actions'; import { normalizeFields } from './normalize-fields'; +import BulkActionsToolbar from './bulk-actions-toolbar'; const defaultGetItemId = ( item ) => item.id; const defaultOnSelectionChange = () => {}; @@ -142,6 +143,16 @@ export default function DataViews( { onChangeView={ onChangeView } paginationInfo={ paginationInfo } /> + { [ LAYOUT_TABLE, LAYOUT_GRID ].includes( view.type ) && + hasPossibleBulkAction && ( + + ) } ); } diff --git a/packages/dataviews/src/item-actions.js b/packages/dataviews/src/item-actions.js index db4da0d4924896..2d928cdbd451b5 100644 --- a/packages/dataviews/src/item-actions.js +++ b/packages/dataviews/src/item-actions.js @@ -47,11 +47,22 @@ function DropdownMenuItemTrigger( { action, onClick } ) { ); } -function ActionWithModal( { action, item, ActionTrigger } ) { +export function ActionWithModal( { + action, + items, + ActionTrigger, + onActionStart, + onActionPerformed, + isBusy, +} ) { const [ isModalOpen, setIsModalOpen ] = useState( false ); const actionTriggerProps = { action, - onClick: () => setIsModalOpen( true ), + onClick: () => { + setIsModalOpen( true ); + }, + items, + isBusy, }; const { RenderModal, hideModalHeader } = action; return ( @@ -69,8 +80,10 @@ function ActionWithModal( { action, item, ActionTrigger } ) { ) }` } > setIsModalOpen( false ) } + onActionStart={ onActionStart } + onActionPerformed={ onActionPerformed } /> ) } @@ -87,7 +100,7 @@ function ActionsDropdownMenuGroup( { actions, item } ) { ); @@ -139,7 +152,7 @@ export default function ItemActions( { item, actions, isCompact } ) { ); diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index d2d61ee383173b..95c0f4f22c92fc 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -790,3 +790,40 @@ } } } + + +.dataviews-bulk-actions-toolbar-wrapper { + display: flex; + flex-grow: 1; + width: 100%; +} + +.dataviews-bulk-actions { + position: absolute; + display: flex; + flex-direction: column; + align-content: center; + flex-wrap: wrap; + width: 100%; + bottom: $grid-unit-30; + + .components-accessible-toolbar { + border-color: $gray-300; + box-shadow: $shadow-popover; + + .components-toolbar-group { + border-color: $gray-200; + + &:last-child { + border: 0; + } + } + } + + .dataviews-bulk-actions__selection-count { + display: flex; + align-items: center; + margin: 0 $grid-unit-10 0 $grid-unit-15; + color: $gray-700; + } +} diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index a6ff1f77486c99..ef4c26a1960a76 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -43,7 +43,13 @@ const trashPostAction = { }, supportsBulk: true, hideModalHeader: true, - RenderModal: ( { items: posts, closeModal, onActionPerformed } ) => { + RenderModal: ( { + items: posts, + closeModal, + onActionStart, + onActionPerformed, + } ) => { + const [ isBusy, setIsBusy ] = useState( false ); const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { deleteEntityRecord } = useDispatch( coreStore ); @@ -67,12 +73,20 @@ const trashPostAction = { ) } - + { __( 'Cancel' ) } { + setIsBusy( true ); + if ( onActionStart ) { + onActionStart( posts ); + } const promiseResult = await Promise.allSettled( posts.map( ( post ) => { return deleteEntityRecord( @@ -161,8 +175,10 @@ const trashPostAction = { if ( onActionPerformed ) { onActionPerformed( posts ); } + setIsBusy( false ); closeModal(); } } + isBusy={ isBusy } > { __( 'Delete' ) } @@ -296,9 +312,9 @@ function useRestorePostAction() { return status === 'trash'; }, async callback( posts, onActionPerformed ) { - try { - for ( const post of posts ) { - await editEntityRecord( + await Promise.allSettled( + posts.map( ( post ) => { + return editEntityRecord( 'postType', post.type, post.id, @@ -306,14 +322,24 @@ function useRestorePostAction() { status: 'draft', } ); - await saveEditedEntityRecord( + } ) + ); + const promiseResult = await Promise.allSettled( + posts.map( ( post ) => { + return saveEditedEntityRecord( 'postType', post.type, post.id, { throwOnError: true } ); - } + } ) + ); + if ( + promiseResult.every( + ( { status } ) => status === 'fulfilled' + ) + ) { createSuccessNotice( posts.length > 1 ? sprintf( @@ -334,25 +360,56 @@ function useRestorePostAction() { if ( onActionPerformed ) { onActionPerformed( posts ); } - } catch ( error ) { + } else { + // If there was at lease one failure. let errorMessage; - if ( - error.message && - error.code !== 'unknown_error' && - error.message - ) { - errorMessage = error.message; - } else if ( posts.length > 1 ) { - errorMessage = __( - 'An error occurred while restoring the posts.' - ); + // If we were trying to move a single post to the trash. + if ( promiseResult.length === 1 ) { + if ( promiseResult[ 0 ].reason?.message ) { + errorMessage = promiseResult[ 0 ].reason.message; + } else { + errorMessage = __( + 'An error occurred while restoring the post.' + ); + } + // If we were trying to move multiple posts to the trash } else { - errorMessage = __( - 'An error occurred while restoring the post.' + 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 restoring the posts.' + ); + } else if ( errorMessages.size === 1 ) { + errorMessage = sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while restoring the posts: %s' + ), + [ ...errorMessages ][ 0 ] + ); + } else { + errorMessage = sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while restoring the posts: %s' + ), + [ ...errorMessages ].join( ',' ) + ); + } } - - createErrorNotice( errorMessage, { type: 'snackbar' } ); + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); } }, } ), @@ -504,9 +561,16 @@ const resetTemplateAction = { id: 'reset-template', label: __( 'Reset' ), isEligible: isTemplateRevertable, + icon: backup, supportsBulk: true, hideModalHeader: true, - RenderModal: ( { items, closeModal, onActionPerformed } ) => { + RenderModal: ( { + items, + closeModal, + onActionStart, + onActionPerformed, + } ) => { + const [ isBusy, setIsBusy ] = useState( false ); const { revertTemplate } = unlock( useDispatch( editorStore ) ); const { saveEditedEntityRecord } = useDispatch( coreStore ); const { createSuccessNotice, createErrorNotice } = @@ -576,16 +640,26 @@ const resetTemplateAction = { { __( 'Reset to default and clear all customizations?' ) } - + { __( 'Cancel' ) } { + setIsBusy( true ); + if ( onActionStart ) { + onActionStart( items ); + } await onConfirm( items ); onActionPerformed?.( items ); closeModal(); + isBusy( false ); } } + isBusy={ isBusy } > { __( 'Reset' ) } @@ -616,9 +690,16 @@ const deleteTemplateAction = { id: 'delete-template', label: __( 'Delete' ), isEligible: isTemplateRemovable, + icon: trash, supportsBulk: true, hideModalHeader: true, - RenderModal: ( { items: templates, closeModal, onActionPerformed } ) => { + RenderModal: ( { + items: templates, + closeModal, + onActionStart, + onActionPerformed, + } ) => { + const [ isBusy, setIsBusy ] = useState( false ); const { removeTemplates } = unlock( useDispatch( editorStore ) ); return ( @@ -642,18 +723,28 @@ const deleteTemplateAction = { ) } - + { __( 'Cancel' ) } { + setIsBusy( true ); + if ( onActionStart ) { + onActionStart( templates ); + } await removeTemplates( templates, { allowUndo: false, } ); onActionPerformed?.( templates ); + setIsBusy( false ); closeModal(); } } + isBusy={ isBusy } > { __( 'Delete' ) } @@ -821,8 +912,7 @@ export function usePostActions( onActionPerformed, actionIds = null ) { RenderModal: ( props ) => { return ( { if ( props.onActionPerformed ) { props.onActionPerformed(