From 6dc13a04fca3ad57a9fcf0a212617281d4de48f4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 19 Apr 2024 18:01:22 +0100 Subject: [PATCH 1/9] Implement busy states on the toolbar --- .../dataviews/src/bulk-actions-toolbar.js | 224 ++++++++++++++++++ packages/dataviews/src/dataviews.js | 11 + packages/dataviews/src/item-actions.js | 23 +- packages/dataviews/src/style.scss | 37 +++ .../src/components/post-actions/actions.js | 142 +++++++++-- 5 files changed, 406 insertions(+), 31 deletions(-) create mode 100644 packages/dataviews/src/bulk-actions-toolbar.js diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js new file mode 100644 index 0000000000000..1b9bb5f2ffed2 --- /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 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..345ded9cb9e6d 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -783,3 +783,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 763b354010bf1..dfe0e4e30e0a3 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 = { ) } - @@ -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.' ); } - - createErrorNotice( errorMessage, { type: 'snackbar' } ); + // If we were trying to move multiple posts to the trash + } 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 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', + } ); } }, } ), @@ -618,9 +675,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 +754,26 @@ const resetTemplateAction = { { __( 'Reset to default and clear all customizations?' ) } - @@ -730,9 +804,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 +837,28 @@ const deleteTemplateAction = { ) } - @@ -936,8 +1027,7 @@ export function usePostActions( onActionPerformed, actionIds = null ) { RenderModal: ( props ) => { return ( { if ( props.onActionPerformed ) { props.onActionPerformed( From 606d12e4d5e224622e682645ac8b2ac3088fb952 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 30 Apr 2024 12:38:19 +0100 Subject: [PATCH 2/9] lint fixes --- packages/editor/src/components/post-actions/actions.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index dfe0e4e30e0a3..f55f92c2ad45e 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -367,11 +367,11 @@ function useRestorePostAction() { if ( promiseResult.length === 1 ) { if ( promiseResult[ 0 ].reason?.message ) { errorMessage = promiseResult[ 0 ].reason.message; - } else { - errorMessage = __( - 'An error occurred while restoring the post.' - ); - } + } else { + errorMessage = __( + 'An error occurred while restoring the post.' + ); + } // If we were trying to move multiple posts to the trash } else { const errorMessages = new Set(); From 8888db34cc88ab2d850d82b45ff25d53a2cabe04 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 30 Apr 2024 16:14:05 +0100 Subject: [PATCH 3/9] only show busy on the modal button --- packages/dataviews/src/bulk-actions-toolbar.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js index 1b9bb5f2ffed2..8b78b9b8ee8c2 100644 --- a/packages/dataviews/src/bulk-actions-toolbar.js +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -91,7 +91,6 @@ function renderToolbarContent( action={ action } items={ selectedItems } ActionTrigger={ ActionTrigger } - isBusy={ actionInProgress === action.id } onActionStart={ () => { setActionInProgress( action.id ); } } From cdd1277f5e8149bb39a0162b6cef3f10554002ae Mon Sep 17 00:00:00 2001 From: James Koster Date: Wed, 1 May 2024 11:29:32 +0100 Subject: [PATCH 4/9] busy button styling --- packages/dataviews/src/style.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index 345ded9cb9e6d..c243e283d6a4b 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -789,6 +789,14 @@ display: flex; flex-grow: 1; width: 100%; + + .components-toolbar-group { + align-items: center; + } + + .components-button.is-busy { + max-height: $button-size; + } } .dataviews-bulk-actions { From 5ea844335b8912b7e57b0cce64267ef0b6074a46 Mon Sep 17 00:00:00 2001 From: James Koster Date: Wed, 1 May 2024 11:36:10 +0100 Subject: [PATCH 5/9] fix zindex, style label --- packages/base-styles/_z-index.scss | 3 +++ packages/dataviews/src/style.scss | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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/style.scss b/packages/dataviews/src/style.scss index c243e283d6a4b..e8e8973ebf679 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -807,6 +807,7 @@ flex-wrap: wrap; width: 100%; bottom: $grid-unit-30; + z-index: z-index(".dataviews-bulk-actions"); .components-accessible-toolbar { border-color: $gray-300; @@ -824,7 +825,6 @@ .dataviews-bulk-actions__selection-count { display: flex; align-items: center; - margin: 0 $grid-unit-10 0 $grid-unit-15; - color: $gray-700; + margin: 0 $grid-unit-10 0 $grid-unit-10; } } From 9fe5b87b2a3736301d91e6aa81560c50e9c96684 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 May 2024 10:51:38 +0100 Subject: [PATCH 6/9] Enable action on toolbar as long as action is allowed for on item --- packages/dataviews/src/bulk-actions-toolbar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js index 8b78b9b8ee8c2..6b13e8b4aa824 100644 --- a/packages/dataviews/src/bulk-actions-toolbar.js +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -39,7 +39,7 @@ const SNACKBAR_VARIANTS = { function ActionTrigger( { action, onClick, items, isBusy } ) { const isDisabled = useMemo( () => { - return isBusy || items.some( ( item ) => ! action.isEligible( item ) ); + return isBusy || items.every( ( item ) => ! action.isEligible( item ) ); }, [ action, items, isBusy ] ); return ( Date: Thu, 2 May 2024 14:36:08 +0100 Subject: [PATCH 7/9] fix bug where delete also reverts --- .../dataviews/src/bulk-actions-toolbar.js | 81 ++++++++++++------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js index 6b13e8b4aa824..10783c055d712 100644 --- a/packages/dataviews/src/bulk-actions-toolbar.js +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -37,26 +37,66 @@ const SNACKBAR_VARIANTS = { }, }; -function ActionTrigger( { action, onClick, items, isBusy } ) { - const isDisabled = useMemo( () => { - return isBusy || items.every( ( item ) => ! action.isEligible( item ) ); - }, [ action, items, isBusy ] ); +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, @@ -84,34 +124,13 @@ function renderToolbarContent( { 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 } + selectedItems={ selectedItems } + actionInProgress={ actionInProgress } + setActionInProgress={ setActionInProgress } /> ); } ) } From e2e3ebe1a3b1648098a03cff07c9024d6b5b2413 Mon Sep 17 00:00:00 2001 From: James Koster Date: Fri, 3 May 2024 15:59:26 +0100 Subject: [PATCH 8/9] tooltip position --- packages/dataviews/src/bulk-actions-toolbar.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js index 10783c055d712..c9a479a0222c4 100644 --- a/packages/dataviews/src/bulk-actions-toolbar.js +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -48,6 +48,7 @@ function ActionTrigger( { action, onClick, isBusy } ) { onClick={ onClick } isBusy={ isBusy } isDisabled={ isBusy } + tooltipPosition="top" /> ); } @@ -139,6 +140,7 @@ function renderToolbarContent( { From ec8653319fd523a153c00f19d7201024aff7869b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 9 May 2024 12:32:11 +0100 Subject: [PATCH 9/9] disabled while busy --- packages/dataviews/src/bulk-actions-toolbar.js | 4 ++-- .../editor/src/components/post-actions/actions.js | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/dataviews/src/bulk-actions-toolbar.js b/packages/dataviews/src/bulk-actions-toolbar.js index c9a479a0222c4..3df9b64192fd3 100644 --- a/packages/dataviews/src/bulk-actions-toolbar.js +++ b/packages/dataviews/src/bulk-actions-toolbar.js @@ -47,7 +47,7 @@ function ActionTrigger( { action, onClick, isBusy } ) { size="compact" onClick={ onClick } isBusy={ isBusy } - isDisabled={ isBusy } + __experimentalIsFocusable tooltipPosition="top" /> ); @@ -142,7 +142,7 @@ function renderToolbarContent( showTooltip tooltipPosition="top" label={ __( 'Cancel' ) } - isDisabled={ !! actionInProgress } + disabled={ !! actionInProgress } onClick={ () => { setSelection( EMPTY_ARRAY ); } } diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index f55f92c2ad45e..f3bf0f9c5c66b 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -76,7 +76,8 @@ const trashPostAction = { @@ -179,6 +180,8 @@ const trashPostAction = { closeModal(); } } isBusy={ isBusy } + disabled={ isBusy } + __experimentalIsFocusable > { __( 'Delete' ) } @@ -757,7 +760,8 @@ const resetTemplateAction = { @@ -774,6 +778,8 @@ const resetTemplateAction = { isBusy( false ); } } isBusy={ isBusy } + disabled={ isBusy } + __experimentalIsFocusable > { __( 'Reset' ) } @@ -840,7 +846,8 @@ const deleteTemplateAction = { @@ -859,6 +866,8 @@ const deleteTemplateAction = { closeModal(); } } isBusy={ isBusy } + disabled={ isBusy } + __experimentalIsFocusable > { __( 'Delete' ) }