diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 1191c16670ba1..11fc402ab1268 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -209,6 +209,9 @@ $z-layers: ( // Ensure checkbox + actions don't overlap table header ".dataviews-view-table thead": 1, + + // Ensure quick actions toolbar appear above pagination + ".dataviews-bulk-actions": 2, ); @function z-index( $key ) { diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js new file mode 100644 index 0000000000000..3df9b64192fd3 --- /dev/null +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -0,0 +1,244 @@ +/** + * 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, isBusy } ) { + return ( + + ); +} + +const EMPTY_ARRAY = []; + +function ActionButton( { + action, + selectedItems, + actionInProgress, + setActionInProgress, +} ) { + const selectedEligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => { + return action.isEligible( item ); + } ); + }, [ action, selectedItems ] ); + if ( !! action.RenderModal ) { + return ( + { + setActionInProgress( action.id ); + } } + onActionPerformed={ () => { + setActionInProgress( null ); + } } + /> + ); + } + return ( + { + setActionInProgress( action.id ); + action.callback( selectedItems, () => { + setActionInProgress( action.id ); + } ); + } } + isBusy={ actionInProgress === action.id } + /> + ); +} + +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 ) => { + return ( + + ); + } ) } + + + { + 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 d8d6281af9498..33f0da08c0ba5 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -15,6 +15,7 @@ import { LAYOUT_TABLE, LAYOUT_GRID } from './constants'; import { VIEW_LAYOUTS } from './layouts'; import BulkActions from './bulk-actions'; import { normalizeFields } from './normalize-fields'; +import BulkActionsToolbar from './bulk-actions-toolbar'; const defaultGetItemId = ( item ) => item.id; const defaultOnSelectionChange = () => {}; @@ -143,6 +144,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 db4da0d492489..2d928cdbd451b 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 6efa7884c8adf..e8e8973ebf679 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -783,3 +783,48 @@ } } } + + +.dataviews-bulk-actions-toolbar-wrapper { + display: flex; + flex-grow: 1; + width: 100%; + + .components-toolbar-group { + align-items: center; + } + + .components-button.is-busy { + max-height: $button-size; + } +} + +.dataviews-bulk-actions { + position: absolute; + display: flex; + flex-direction: column; + align-content: center; + flex-wrap: wrap; + width: 100%; + bottom: $grid-unit-30; + z-index: z-index(".dataviews-bulk-actions"); + + .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-10; + } +} diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index 763b354010bf1..f3bf0f9c5c66b 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,21 @@ const trashPostAction = { ) } - @@ -296,9 +315,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 +325,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 +363,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', + } ); } }, } ), @@ -618,9 +678,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 } = @@ -690,16 +757,29 @@ const resetTemplateAction = { { __( 'Reset to default and clear all customizations?' ) } - @@ -730,9 +810,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 ( @@ -756,18 +843,31 @@ const deleteTemplateAction = { ) } - @@ -936,8 +1036,7 @@ export function usePostActions( onActionPerformed, actionIds = null ) { RenderModal: ( props ) => { return ( { if ( props.onActionPerformed ) { props.onActionPerformed(