diff --git a/packages/dataviews/src/bulk-actions.js b/packages/dataviews/src/bulk-actions.js new file mode 100644 index 00000000000000..76224e9f253448 --- /dev/null +++ b/packages/dataviews/src/bulk-actions.js @@ -0,0 +1,182 @@ +/** + * WordPress dependencies + */ +import { + privateApis as componentsPrivateApis, + Button, + Modal, +} from '@wordpress/components'; +import { __, sprintf, _n } from '@wordpress/i18n'; +import { useMemo, useState } 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 } ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + const { RenderModal, hideModalHeader } = action; + return ( + { + setActionWithModal( undefined ); + } } + overlayClassName="dataviews-action-modal" + > + setActionWithModal( undefined ) } + /> + + ); +} + +function BulkActionItem( { + action, + selectedItems, + onMenuOpenChange, + setActionWithModal, +} ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + return ( + { + event.preventDefault(); + if ( !! action.RenderModal ) { + onMenuOpenChange( false ); + setActionWithModal( action ); + } else { + await action.callback( eligibleItems ); + } + } } + suffix={ + eligibleItems.length > 0 ? eligibleItems.length : undefined + } + > + { action.label } + + ); +} + +function ActionsMenuGroup( { + actions, + selectedItems, + onMenuOpenChange, + setActionWithModal, +} ) { + const bulkActions = actions.filter( ( action ) => action.supportsBulk ); + if ( bulkActions.length === 0 ) { + return null; + } + return ( + <> + + { bulkActions.map( ( action ) => ( + + ) ) } + + + + ); +} + +export default function BulkActions( { + data, + actions, + selection, + onSelectionChange, + getItemId, +} ) { + 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 ] ); + return ( + <> + + { selection.length + ? sprintf( + /* translators: %d: Number of items. */ + _n( + 'Edit %d item', + 'Edit %d items', + selection.length + ), + selection.length + ) + : __( 'Bulk edit' ) } + + } + > + + + { + event.preventDefault(); + onSelectionChange( data ); + } } + suffix={ data.length } + > + { __( 'Select all' ) } + + { + event.preventDefault(); + onSelectionChange( [] ); + } } + > + { __( 'Deselect' ) } + + + + { actionWithModal && ( + + ) } + + ); +} diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 9e7b45d04ef87f..fcaaacb38bf912 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -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'; export default function DataViews( { view, @@ -30,6 +31,7 @@ export default function DataViews( { supportedLayouts, onSelectionChange, deferredRendering, + labels, } ) { const [ selection, setSelection ] = useState( [] ); @@ -55,6 +57,15 @@ export default function DataViews( { className="dataviews__filters-view-actions" > + { view.type === LAYOUT_TABLE && ( + + ) } { search && ( setIsModalOpen( false ) } /> @@ -93,7 +93,7 @@ function ActionsDropdownMenuGroup( { actions, item } ) { action.callback( item ) } + onClick={ () => action.callback( [ item ] ) } /> ); } ) } @@ -154,7 +154,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 b35e11deae7f4f..ebcbfc2da039b1 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -286,3 +286,15 @@ .dataviews-loading { padding: 0 $grid-unit-40; } + +.dataviews-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; +} diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index e08449b76491df..39138df7d0e9c7 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -15,6 +15,7 @@ import { Button, Icon, privateApis as componentsPrivateApis, + CheckboxControl, } from '@wordpress/components'; import { Children, Fragment } from '@wordpress/element'; @@ -321,6 +322,77 @@ function WithSeparators( { children } ) { ) ); } +function BulkSelectionCheckbox( { selection, onSelectionChange, data } ) { + const areAllSelected = selection && selection.length === data.length; + return ( + { + if ( areAllSelected ) { + onSelectionChange( [] ); + } else { + onSelectionChange( data ); + } + } } + label={ areAllSelected ? __( 'Deselect all' ) : __( 'Select all' ) } + /> + ); +} + +function SingleSelectionCheckbox( { + selection, + onSelectionChange, + item, + labels, + data, + getItemId, +} ) { + const id = getItemId?.( item ); + const isSelected = selection.includes( id ); + let selectionLabel; + if ( isSelected ) { + selectionLabel = labels?.getDeselectLabel + ? labels?.getDeselectLabel( item ) + : __( 'Deselect item' ); + } else { + selectionLabel = labels?.getSelectLabel + ? labels?.getSelectLabel( item ) + : __( 'Select a new 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, @@ -330,6 +402,9 @@ function ViewTable( { getItemId, isLoading = false, deferredRendering, + selection, + onSelectionChange, + labels, } ) { const visibleFields = fields.filter( ( field ) => @@ -356,6 +431,22 @@ function ViewTable( { + { !! selection && ( + + ) } { visibleFields.map( ( field ) => ( + { !! selection && ( + + ) } { visibleFields.map( ( field ) => (
+ + { usedData.map( ( item, index ) => (
+ + { + 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-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 17736abdfc55c0..15717537bb38f2 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -5,7 +5,7 @@ import { __experimentalView as View, __experimentalVStack as VStack, } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useEntityRecords, store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { useState, useMemo, useCallback, useEffect } from '@wordpress/element'; @@ -342,6 +342,22 @@ export default function PagePages() { onChangeView={ onChangeView } onSelectionChange={ onSelectionChange } deferredRendering={ false } + labels={ { + getSelectLabel: ( item ) => { + return sprintf( + // translators: %s: The title of the page. + __( 'Select page: %s' ), + item.title?.rendered || item.slug + ); + }, + getDeselectLabel: ( item ) => { + return sprintf( + // translators: %s: The title of the page. + __( 'Deselect page: %s' ), + item.title?.rendered || item.slug + ); + }, + } } /> { view.type === LAYOUT_LIST && ( diff --git a/packages/edit-site/src/components/page-templates/index.js b/packages/edit-site/src/components/page-templates/index.js index 30e7797ec0b2d2..41e8da5f066201 100644 --- a/packages/edit-site/src/components/page-templates/index.js +++ b/packages/edit-site/src/components/page-templates/index.js @@ -14,7 +14,7 @@ import { __experimentalVStack as VStack, VisuallyHidden, } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useState, useMemo, useCallback } from '@wordpress/element'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; @@ -386,6 +386,22 @@ export default function DataviewsTemplates() { deferredRendering={ ! view.hiddenFields?.includes( 'preview' ) } + labels={ { + getSelectLabel: ( item ) => { + return sprintf( + // translators: %s: The title of the template. + __( 'Select template: %s' ), + item.title?.rendered || item.slug + ); + }, + getDeselectLabel: ( item ) => { + return sprintf( + // translators: %s: The title of the template. + __( 'Deselect template: %s' ), + item.title?.rendered || item.slug + ); + }, + } } /> { view.type === LAYOUT_LIST && ( 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..d78591baf01ecc 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'; @@ -36,21 +36,40 @@ export function useResetTemplateAction() { 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 + await Promise.all( + templates.map( ( template ) => { + return revertTemplate( template, { + allowUndo: false, + } ); + } ) + ); + await Promise.all( + templates.map( ( template ) => { + return 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.' ), + decodeEntities( templates.length ) + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reverted.' ), + decodeEntities( + templates[ 0 ].title.rendered + ) + ), { type: 'snackbar', id: 'edit-site-template-reverted', @@ -58,12 +77,16 @@ export function useResetTemplateAction() { ); } catch ( error ) { const fallbackErrorMessage = - template.type === TEMPLATE_POST_TYPE - ? __( - 'An error occurred while reverting the template.' + templates[ 0 ].type === TEMPLATE_POST_TYPE + ? _n( + 'An error occurred while reverting the template.', + 'An error occurred while reverting the templates.', + templates.length ) - : __( - 'An error occurred while reverting the template part.' + : _n( + 'An error occurred while reverting the template part.', + 'An error occurred while reverting the template parts.', + templates.length ); const errorMessage = error.message && error.code !== 'unknown_error' @@ -89,17 +112,31 @@ export const deleteTemplateAction = { isPrimary: true, icon: trash, isEligible: isTemplateRemovable, + supportsBulk: true, hideModalHeader: true, - RenderModal: ( { item: template, closeModal } ) => { + RenderModal: ( { items: templates, closeModal } ) => { const { removeTemplate } = useDispatch( editSiteStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); 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: %s: The template or template part's title. + __( + 'Are you sure you want to delete %s items?' + ), + decodeEntities( templates.length ) + ) + : sprintf( + // translators: %s: The template or template part's title. + __( 'Are you sure you want to delete "%s"?' ), + decodeEntities( + templates && templates[ 0 ]?.title?.rendered + ) + ) } @@ -126,7 +201,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 {