From 28c8acd0f46e748f21d6559a8608a5eefdb135bc Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 23 Nov 2023 12:10:18 +0000 Subject: [PATCH 1/4] Add: Bulk actions API and trash action. --- .../src/components/bulk-actions/index.js | 66 +++++++++++++ .../src/components/dataviews/bulk-actions.js | 94 +++++++++++++++++++ .../src/components/dataviews/dataviews.js | 16 ++++ .../src/components/dataviews/style.scss | 11 +++ .../src/components/dataviews/view-list.js | 71 +++++++++++++- .../src/components/page-pages/index.js | 26 +++++ 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/edit-site/src/components/bulk-actions/index.js create mode 100644 packages/edit-site/src/components/dataviews/bulk-actions.js diff --git a/packages/edit-site/src/components/bulk-actions/index.js b/packages/edit-site/src/components/bulk-actions/index.js new file mode 100644 index 0000000000000..390f9600a0810 --- /dev/null +++ b/packages/edit-site/src/components/bulk-actions/index.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import { trash } from '@wordpress/icons'; +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export function useBulkTrashPostAction() { + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); + return useMemo( + () => ( { + id: 'move-to-trash', + label: __( 'Move to Trash' ), + isPrimary: true, + icon: trash, + isEligible( data, selection ) { + console.log( { + p: data.filter( ( post ) => selection.includes( post.id ) ), + } ); + return ! data + .filter( ( post ) => selection.includes( post.id ) ) + .some( ( post ) => post.status === 'trash' ); + }, + async callback( data, selection ) { + const postsToDelete = data.filter( ( post ) => + selection.includes( post.id ) + ); + try { + await Promise.all( + postsToDelete.map( async ( post ) => { + deleteEntityRecord( + 'postType', + post.type, + post.id, + {}, + { throwOnError: true } + ); + } ) + ); + createSuccessNotice( + __( 'The selected posts were moved to the trash.' ), + { + type: 'snackbar', + id: 'edit-site-page-trashed', + } + ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while moving the posts to the trash.' + ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }, + } ), + [ createErrorNotice, createSuccessNotice, deleteEntityRecord ] + ); +} diff --git a/packages/edit-site/src/components/dataviews/bulk-actions.js b/packages/edit-site/src/components/dataviews/bulk-actions.js new file mode 100644 index 0000000000000..cd55aa94c9b2a --- /dev/null +++ b/packages/edit-site/src/components/dataviews/bulk-actions.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { + ToolbarButton, + Toolbar, + ToolbarGroup, + ToolbarItem, + Popover, +} from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; + +function PrimaryActionTrigger( { action, onClick } ) { + return ( + + ); +} + +const EMPTY_ARRAY = []; + +export default function BulkActions( { + data, + selection, + bulkActions = EMPTY_ARRAY, + setSelection, +} ) { + const primaryActions = useMemo( + () => + bulkActions.filter( ( action ) => { + return action.isPrimary && action.isEligible( data, selection ); + } ), + [ bulkActions, data, selection ] + ); + if ( + ( selection && selection.length === 0 ) || + primaryActions.length === 0 + ) { + return null; + } + return ( + + +
+ + {} } disabled={ true }> + { + // translators: %s: Total number of selected items. + sprintf( + // translators: %s: Total number of selected items. + _n( + '%s item selected', + '%s items selected', + selection.length + ), + selection.length + ) + } + + { + setSelection( EMPTY_ARRAY ); + } } + > + { __( 'Deselect' ) } + + + + { primaryActions.map( ( action ) => { + return ( + + action.callback( data, selection ) + } + /> + ); + } ) } + +
+
+
+ ); +} diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/edit-site/src/components/dataviews/dataviews.js index 78d0ea83abb8e..53ee92fb1d660 100644 --- a/packages/edit-site/src/components/dataviews/dataviews.js +++ b/packages/edit-site/src/components/dataviews/dataviews.js @@ -4,6 +4,7 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, + Popover, } from '@wordpress/components'; import { useMemo } from '@wordpress/element'; @@ -17,6 +18,7 @@ import Filters from './filters'; import Search from './search'; import { ViewGrid } from './view-grid'; import { ViewSideBySide } from './view-side-by-side'; +import BulkActions from './bulk-actions'; // To do: convert to view type registry. export const viewTypeSupportsMap = { @@ -45,6 +47,9 @@ export default function DataViews( { isLoading = false, paginationInfo, supportedLayouts, + selection, + setSelection, + bulkActions, } ) { const ViewComponent = viewTypeMap[ view.type ]; const _fields = useMemo( () => { @@ -89,12 +94,23 @@ export default function DataViews( { data={ data } getItemId={ getItemId } isLoading={ isLoading } + selection={ selection } + setSelection={ setSelection } /> + +
+ +
); diff --git a/packages/edit-site/src/components/dataviews/style.scss b/packages/edit-site/src/components/dataviews/style.scss index 2d403caa6d4e0..6f52b67c28c58 100644 --- a/packages/edit-site/src/components/dataviews/style.scss +++ b/packages/edit-site/src/components/dataviews/style.scss @@ -78,3 +78,14 @@ .dataviews-action-modal { z-index: z-index(".dataviews-action-modal"); } + +.dataviews-bulk-actions-popover .components-popover__content { + min-width: max-content; +} + +.dataviews-bulk-actions-toolbar-wrapper { + display: flex; + flex-grow: 1; + width: 100%; +} + diff --git a/packages/edit-site/src/components/dataviews/view-list.js b/packages/edit-site/src/components/dataviews/view-list.js index c5d0bd0d340fe..6fcd0bca70bd8 100644 --- a/packages/edit-site/src/components/dataviews/view-list.js +++ b/packages/edit-site/src/components/dataviews/view-list.js @@ -29,6 +29,7 @@ import { Button, Icon, privateApis as componentsPrivateApis, + CheckboxControl, } from '@wordpress/components'; import { useMemo, Children, Fragment } from '@wordpress/element'; @@ -238,9 +239,12 @@ function ViewList( { getItemId, isLoading = false, paginationInfo, + selection, + setSelection, } ) { + const areAllSelected = selection && selection.length === data.length; const columns = useMemo( () => { - const _columns = fields.map( ( field ) => { + const fieldsColumns = fields.map( ( field ) => { const { render, getValue, ...column } = field; column.cell = ( props ) => render( { item: props.row.original, view } ); @@ -249,6 +253,61 @@ function ViewList( { } return column; } ); + const _columns = + selection !== undefined + ? [ + { + header: ( + { + if ( areAllSelected ) { + setSelection( [] ); + } else { + setSelection( + data.map( ( { id } ) => id ) + ); + } + } } + /> + ), + id: 'selection', + cell: ( props ) => { + //console.log({ props }); + const item = props.row.original; + const isSelected = selection.includes( + item.id + ); + //console.log({ item, isSelected }); + return ( + { + if ( ! isSelected ) { + const newSelection = [ + ...selection, + item.id, + ]; + setSelection( newSelection ); + } else { + setSelection( + selection.filter( + ( id ) => id !== item.id + ) + ); + } + } } + /> + ); + }, + enableHiding: false, + width: 40, + }, + ...fieldsColumns, + ] + : fieldsColumns; if ( actions?.length ) { _columns.push( { header: __( 'Actions' ), @@ -266,7 +325,15 @@ function ViewList( { } return _columns; - }, [ fields, actions, view ] ); + }, [ + areAllSelected, + fields, + actions, + view, + selection, + setSelection, + data, + ] ); const columnVisibility = useMemo( () => { if ( ! view.hiddenFields?.length ) { diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 5e42ce7012047..a967d3c56a3ed 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -28,6 +28,7 @@ import { viewPostAction, useEditPostAction, } from '../actions'; +import { useBulkTrashPostAction } from '../bulk-actions'; import SideEditor from './side-editor'; import Media from '../media'; import { unlock } from '../../lock-unlock'; @@ -157,6 +158,20 @@ export default function PagePages() { totalPages, } = useEntityRecords( 'postType', postType, queryArgs ); + useEffect( () => { + if ( + selection.some( + ( id ) => ! pages?.some( ( page ) => page.id === id ) + ) + ) { + setSelection( + selection.filter( ( id ) => + pages?.some( ( page ) => page.id === id ) + ) + ); + } + }, [ pages, selection ] ); + const { records: authors, isResolving: isLoadingAuthors } = useEntityRecords( 'root', 'user' ); @@ -262,6 +277,9 @@ export default function PagePages() { const permanentlyDeletePostAction = usePermanentlyDeletePostAction(); const restorePostAction = useRestorePostAction(); const editPostAction = useEditPostAction(); + + const bulkTrashPostAction = useBulkTrashPostAction(); + const actions = useMemo( () => [ viewPostAction, @@ -273,6 +291,11 @@ export default function PagePages() { ], [ permanentlyDeletePostAction, restorePostAction, editPostAction ] ); + + const bulkActions = useMemo( + () => [ bulkTrashPostAction ], + [ bulkTrashPostAction ] + ); const onChangeView = useCallback( ( viewUpdater ) => { let updatedView = @@ -301,11 +324,14 @@ export default function PagePages() { paginationInfo={ paginationInfo } fields={ fields } actions={ actions } + bulkActions={ bulkActions } data={ pages || EMPTY_ARRAY } getItemId={ ( item ) => item.id } isLoading={ isLoadingPages || isLoadingAuthors } view={ view } onChangeView={ onChangeView } + selection={ selection } + setSelection={ setSelection } /> { viewTypeSupportsMap[ view.type ].preview && ( From 652a32ea2a97dc69573cd1c8a5a555085584fd89 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 27 Nov 2023 20:04:35 +0000 Subject: [PATCH 2/4] Feedback --- .../src/components/dataviews/view-list.js | 104 ++++++++---------- .../src/components/page-pages/index.js | 1 + 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/view-list.js b/packages/edit-site/src/components/dataviews/view-list.js index 6fcd0bca70bd8..2c9d99f5ea0ae 100644 --- a/packages/edit-site/src/components/dataviews/view-list.js +++ b/packages/edit-site/src/components/dataviews/view-list.js @@ -244,7 +244,7 @@ function ViewList( { } ) { const areAllSelected = selection && selection.length === data.length; const columns = useMemo( () => { - const fieldsColumns = fields.map( ( field ) => { + const _columns = fields.map( ( field ) => { const { render, getValue, ...column } = field; column.cell = ( props ) => render( { item: props.row.original, view } ); @@ -253,61 +253,53 @@ function ViewList( { } return column; } ); - const _columns = - selection !== undefined - ? [ - { - header: ( - { - if ( areAllSelected ) { - setSelection( [] ); - } else { - setSelection( - data.map( ( { id } ) => id ) - ); - } - } } - /> - ), - id: 'selection', - cell: ( props ) => { - //console.log({ props }); - const item = props.row.original; - const isSelected = selection.includes( - item.id - ); - //console.log({ item, isSelected }); - return ( - { - if ( ! isSelected ) { - const newSelection = [ - ...selection, - item.id, - ]; - setSelection( newSelection ); - } else { - setSelection( - selection.filter( - ( id ) => id !== item.id - ) - ); - } - } } - /> - ); - }, - enableHiding: false, - width: 40, - }, - ...fieldsColumns, - ] - : fieldsColumns; + if ( selection !== undefined ) { + _columns.unshift( { + header: ( + { + if ( areAllSelected ) { + setSelection( [] ); + } else { + setSelection( data.map( ( { id } ) => id ) ); + } + } } + /> + ), + id: 'selection', + cell: ( props ) => { + //console.log({ props }); + const item = props.row.original; + const isSelected = selection.includes( item.id ); + //console.log({ item, isSelected }); + return ( + { + if ( ! isSelected ) { + const newSelection = [ + ...selection, + item.id, + ]; + setSelection( newSelection ); + } else { + setSelection( + selection.filter( + ( id ) => id !== item.id + ) + ); + } + } } + /> + ); + }, + enableHiding: false, + width: 40, + } ); + } if ( actions?.length ) { _columns.push( { header: __( 'Actions' ), diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index a967d3c56a3ed..f269c24cb8cec 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -158,6 +158,7 @@ export default function PagePages() { totalPages, } = useEntityRecords( 'postType', postType, queryArgs ); + // Remove any selected pages that are no longer in the list of visible pages. useEffect( () => { if ( selection.some( From 25f4c2b8b65e5773c0771d0126109148b1052db2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 28 Nov 2023 19:23:06 +0000 Subject: [PATCH 3/4] Lint fixes --- .../edit-site/src/components/actions/index.js | 65 +++++--- .../src/components/bulk-actions/index.js | 66 -------- .../src/components/dataviews/bulk-actions.js | 39 ++++- .../src/components/dataviews/dataviews.js | 14 +- .../src/components/dataviews/item-actions.js | 155 ++++++------------ .../src/components/page-pages/index.js | 8 - 6 files changed, 136 insertions(+), 211 deletions(-) delete mode 100644 packages/edit-site/src/components/bulk-actions/index.js diff --git a/packages/edit-site/src/components/actions/index.js b/packages/edit-site/src/components/actions/index.js index ca673e3867bda..3c185cfe0661f 100644 --- a/packages/edit-site/src/components/actions/index.js +++ b/packages/edit-site/src/components/actions/index.js @@ -6,7 +6,7 @@ import { addQueryArgs } from '@wordpress/url'; import { useDispatch } from '@wordpress/data'; import { decodeEntities } from '@wordpress/html-entities'; import { store as coreStore } from '@wordpress/core-data'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, sprintf, _n } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { useMemo } from '@wordpress/element'; import { privateApis as routerPrivateApis } from '@wordpress/router'; @@ -28,23 +28,34 @@ export const trashPostAction = { id: 'move-to-trash', label: __( 'Move to Trash' ), isPrimary: true, + isBulk: true, icon: trash, isEligible( { status } ) { return status !== 'trash'; }, hideModalHeader: true, - RenderModal: ( { item: post, closeModal } ) => { + RenderModal: ( { items: posts, closeModal } ) => { const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { deleteEntityRecord } = useDispatch( coreStore ); return ( - { sprintf( - // translators: %s: The page's title. - __( 'Are you sure you want to delete "%s"?' ), - decodeEntities( post.title.rendered ) - ) } + { posts.length > 1 + ? sprintf( + // translators: %s: The number of posts (always plural). + __( + 'Are you sure you want to delete %s posts?' + ), + decodeEntities( posts.length ) + ) + : sprintf( + // translators: %s: The page's title. + __( 'Are you sure you want to delete "%s"?' ), + decodeEntities( + posts && posts[ 0 ]?.title?.rendered + ) + ) }