From 9f17db8a7bb7bc1e903cfa2c68c51597cf35993e Mon Sep 17 00:00:00 2001 From: Said Akhrarov <36972061+akhrarovsaid@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:06:44 -0400 Subject: [PATCH] fix(ui): toggle list selections off on successful bulk action (#12861) ### What? This PR threads an onSuccess callback to bulk actions which get called after a successful action. In this case, the callback toggles the list selections off after a successful edit many, publish many, or unpublish many. ### Why? To ensure list selections are toggled off after a successful action. ### How? By threading a new onSuccess callback through the actions' props. Fixes #12855 Before [12855-before.mp4](https://private-user-images.githubusercontent.com/65888/456602476-b327f0ba-c140-46be-8c71-7f6bfa74fd67.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTAyODQxMDEsIm5iZiI6MTc1MDI4MzgwMSwicGF0aCI6Ii82NTg4OC80NTY2MDI0NzYtYjMyN2YwYmEtYzE0MC00NmJlLThjNzEtN2Y2YmZhNzRmZDY3Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA2MTglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNjE4VDIxNTY0MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTA0YTE4OTE5MjliZWQxNDM1OTU0ODlhMmY5ZjliNjhlODAyODU5ZmU3ODkzMjI1ODhiOTQyNmY0YzMyMGM0ZmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.hzTLtuzltcpQUAIHYz7JoZ5x7JT4dPP9f-3c-GDf0Zc) After [Draft-Posts---Payload.webm](https://github.com/user-attachments/assets/474fbd9f-c7b3-46f4-ae31-5246cb22b86d) --- .../src/elements/EditMany/DrawerContent.tsx | 11 +- packages/ui/src/elements/EditMany/index.tsx | 7 +- .../elements/PublishMany/DrawerContent.tsx | 8 ++ .../ui/src/elements/PublishMany/index.tsx | 14 ++- .../elements/UnpublishMany/DrawerContent.tsx | 8 ++ .../ui/src/elements/UnpublishMany/index.tsx | 14 ++- .../ui/src/views/List/ListSelection/index.tsx | 7 +- test/bulk-edit/e2e.spec.ts | 106 ++++++++++++++++++ 8 files changed, 167 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index 5306375008..74b6c50ff3 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -29,9 +29,9 @@ import { useTranslation } from '../../providers/Translation/index.js' import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { FieldSelect } from '../FieldSelect/index.js' -import { baseClass, type EditManyProps } from './index.js' import './index.scss' import '../../forms/RenderFields/index.scss' +import { baseClass, type EditManyProps } from './index.js' const Submit: React.FC<{ readonly action: string @@ -123,6 +123,10 @@ type EditManyDrawerContentProps = { * The IDs of the selected items */ ids?: (number | string)[] + /** + * The function to call after a successful action + */ + onSuccess?: () => void /** * Whether all items are selected */ @@ -143,6 +147,7 @@ export const EditManyDrawerContent: React.FC = (prop count, drawerSlug, ids, + onSuccess: onSuccessFromProps, selectAll, selectedFields, setSelectedFields, @@ -263,6 +268,10 @@ export const EditManyDrawerContent: React.FC = (prop ) clearRouteCache() closeModal(drawerSlug) + + if (typeof onSuccessFromProps === 'function') { + onSuccessFromProps() + } } const onFieldSelect = useCallback( diff --git a/packages/ui/src/elements/EditMany/index.tsx b/packages/ui/src/elements/EditMany/index.tsx index a4172840f9..b9aa3a11ae 100644 --- a/packages/ui/src/elements/EditMany/index.tsx +++ b/packages/ui/src/elements/EditMany/index.tsx @@ -22,12 +22,13 @@ export type EditManyProps = { } export const EditMany: React.FC = (props) => { - const { count, selectAll, selected } = useSelection() + const { count, selectAll, selected, toggleAll } = useSelection() return ( toggleAll(false)} selectAll={selectAll === SelectAllStatus.AllAvailable} /> ) @@ -37,9 +38,10 @@ export const EditMany_v4: React.FC< { count: number ids: (number | string)[] + onSuccess?: () => void selectAll: boolean } & EditManyProps -> = ({ collection, count, ids, selectAll }) => { +> = ({ collection, count, ids, onSuccess, selectAll }) => { const { permissions } = useAuth() const { openModal } = useModal() @@ -73,6 +75,7 @@ export const EditMany_v4: React.FC< count={count} drawerSlug={drawerSlug} ids={ids} + onSuccess={onSuccess} selectAll={selectAll} selectedFields={selectedFields} setSelectedFields={setSelectedFields} diff --git a/packages/ui/src/elements/PublishMany/DrawerContent.tsx b/packages/ui/src/elements/PublishMany/DrawerContent.tsx index b818144497..7d81e3dbdf 100644 --- a/packages/ui/src/elements/PublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/PublishMany/DrawerContent.tsx @@ -20,6 +20,7 @@ import { ConfirmationModal } from '../ConfirmationModal/index.js' type PublishManyDrawerContentProps = { drawerSlug: string ids: (number | string)[] + onSuccess?: () => void selectAll: boolean } & PublishManyProps export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { @@ -28,6 +29,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { collection: { slug, labels: { plural, singular } } = {}, drawerSlug, ids, + onSuccess, selectAll, } = props @@ -136,6 +138,11 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { ) clearRouteCache() + + if (typeof onSuccess === 'function') { + onSuccess() + } + return null } @@ -163,6 +170,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { selectAll, clearRouteCache, addDefaultError, + onSuccess, ]) return ( diff --git a/packages/ui/src/elements/PublishMany/index.tsx b/packages/ui/src/elements/PublishMany/index.tsx index 351ee03965..0df29b1856 100644 --- a/packages/ui/src/elements/PublishMany/index.tsx +++ b/packages/ui/src/elements/PublishMany/index.tsx @@ -15,13 +15,14 @@ export type PublishManyProps = { } export const PublishMany: React.FC = (props) => { - const { count, selectAll, selected } = useSelection() + const { count, selectAll, selected, toggleAll } = useSelection() return ( toggleAll(false)} selectAll={selectAll === SelectAllStatus.AllAvailable} /> ) @@ -30,10 +31,18 @@ export const PublishMany: React.FC = (props) => { type PublishMany_v4Props = { count: number ids: (number | string)[] + onSuccess?: () => void selectAll: boolean } & PublishManyProps export const PublishMany_v4: React.FC = (props) => { - const { collection, collection: { slug, versions } = {}, count, ids, selectAll } = props + const { + collection, + collection: { slug, versions } = {}, + count, + ids, + onSuccess, + selectAll, + } = props const { permissions } = useAuth() const { t } = useTranslation() @@ -63,6 +72,7 @@ export const PublishMany_v4: React.FC = (props) => { collection={collection} drawerSlug={drawerSlug} ids={ids} + onSuccess={onSuccess} selectAll={selectAll} /> diff --git a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx index 6c6f2fafa4..bf7ead0a29 100644 --- a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx @@ -20,6 +20,7 @@ import { ConfirmationModal } from '../ConfirmationModal/index.js' type UnpublishManyDrawerContentProps = { drawerSlug: string ids: (number | string)[] + onSuccess?: () => void selectAll: boolean } & UnpublishManyProps @@ -29,6 +30,7 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp collection: { slug, labels: { plural, singular } } = {}, drawerSlug, ids, + onSuccess, selectAll, } = props @@ -126,6 +128,11 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp ) clearRouteCache() + + if (typeof onSuccess === 'function') { + onSuccess() + } + return null } @@ -153,6 +160,7 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp selectAll, clearRouteCache, addDefaultError, + onSuccess, ]) return ( diff --git a/packages/ui/src/elements/UnpublishMany/index.tsx b/packages/ui/src/elements/UnpublishMany/index.tsx index a4df78ee30..b59a955f9f 100644 --- a/packages/ui/src/elements/UnpublishMany/index.tsx +++ b/packages/ui/src/elements/UnpublishMany/index.tsx @@ -15,13 +15,14 @@ export type UnpublishManyProps = { } export const UnpublishMany: React.FC = (props) => { - const { count, selectAll, selected } = useSelection() + const { count, selectAll, selected, toggleAll } = useSelection() return ( toggleAll(false)} selectAll={selectAll === SelectAllStatus.AllAvailable} /> ) @@ -31,10 +32,18 @@ export const UnpublishMany_v4: React.FC< { count: number ids: (number | string)[] + onSuccess?: () => void selectAll: boolean } & UnpublishManyProps > = (props) => { - const { collection, collection: { slug, versions } = {}, count, ids, selectAll } = props + const { + collection, + collection: { slug, versions } = {}, + count, + ids, + onSuccess, + selectAll, + } = props const { t } = useTranslation() const { permissions } = useAuth() @@ -63,6 +72,7 @@ export const UnpublishMany_v4: React.FC< collection={collection} drawerSlug={drawerSlug} ids={ids} + onSuccess={onSuccess} selectAll={selectAll} /> diff --git a/packages/ui/src/views/List/ListSelection/index.tsx b/packages/ui/src/views/List/ListSelection/index.tsx index 4b6a24748c..f87cea25d9 100644 --- a/packages/ui/src/views/List/ListSelection/index.tsx +++ b/packages/ui/src/views/List/ListSelection/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { ClientCollectionConfig } from 'payload' -import React, { Fragment } from 'react' +import React, { Fragment, useCallback } from 'react' import { DeleteMany } from '../../../elements/DeleteMany/index.js' import { EditMany_v4 } from '../../../elements/EditMany/index.js' @@ -27,6 +27,8 @@ export const ListSelection: React.FC = ({ const { count, getSelectedIds, selectAll, toggleAll, totalDocs } = useSelection() const { t } = useTranslation() + const onActionSuccess = useCallback(() => toggleAll(false), [toggleAll]) + if (count === 0) { return null } @@ -53,18 +55,21 @@ export const ListSelection: React.FC = ({ collection={collectionConfig} count={count} ids={getSelectedIds()} + onSuccess={onActionSuccess} selectAll={selectAll === SelectAllStatus.AllAvailable} /> diff --git a/test/bulk-edit/e2e.spec.ts b/test/bulk-edit/e2e.spec.ts index 77aee50d79..3866c13440 100644 --- a/test/bulk-edit/e2e.spec.ts +++ b/test/bulk-edit/e2e.spec.ts @@ -488,6 +488,112 @@ test.describe('Bulk Edit', () => { await expect(field.locator('#field-array__0__noRead')).toBeHidden() await expect(field.locator('#field-array__0__noUpdate')).toBeDisabled() }) + + test('should toggle list selections off on successful publish', async () => { + await deleteAllPosts() + + const postCount = 3 + Array.from({ length: postCount }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}` }, { draft: true }) + }) + + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + + await page.locator('.list-selection__button[aria-label="Publish"]').click() + await page.locator('#publish-posts #confirm-action').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + `Updated ${postCount} Posts successfully.`, + ) + + // eslint-disable-next-line jest-dom/prefer-checked + await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '') + + for (let i = 0; i < postCount; i++) { + // eslint-disable-next-line jest-dom/prefer-checked + await expect(findTableCell(page, '_select', `Post ${i + 1}`)).not.toHaveAttribute( + 'checked', + '', + ) + } + }) + + test('should toggle list selections off on successful unpublish', async () => { + await deleteAllPosts() + + const postCount = 3 + Array.from({ length: postCount }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}`, _status: 'published' }) + }) + + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + + await page.locator('.list-selection__button[aria-label="Unpublish"]').click() + await page.locator('#unpublish-posts #confirm-action').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + `Updated ${postCount} Posts successfully.`, + ) + + // eslint-disable-next-line jest-dom/prefer-checked + await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '') + + for (let i = 0; i < postCount; i++) { + // eslint-disable-next-line jest-dom/prefer-checked + await expect(findTableCell(page, '_select', `Post ${i + 1}`)).not.toHaveAttribute( + 'checked', + '', + ) + } + }) + + test('should toggle list selections off on successful edit', async () => { + await deleteAllPosts() + + const postCount = 3 + Array.from({ length: postCount }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}` }) + }) + + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + + await page.locator('.list-selection__button[aria-label="Edit"]').click() + + const editDrawer = page.locator('dialog#edit-posts') + await expect(editDrawer).toBeVisible() + + const fieldSelect = editDrawer.locator('.field-select') + await expect(fieldSelect).toBeVisible() + + const fieldSelectControl = fieldSelect.locator('.rs__control') + await expect(fieldSelectControl).toBeVisible() + await fieldSelectControl.click() + + const titleOption = fieldSelect.locator('.rs__option:has-text("Title")').first() + await titleOption.click() + + await editDrawer.locator('input#field-title').fill('test') + + await editDrawer.locator('button[type="submit"]:has-text("Publish changes")').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + `Updated ${postCount} Posts successfully.`, + ) + + // eslint-disable-next-line jest-dom/prefer-checked + await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '') + + for (let i = 0; i < postCount; i++) { + // eslint-disable-next-line jest-dom/prefer-checked + await expect(findTableCell(page, '_select', `Post ${i + 1}`)).not.toHaveAttribute( + 'checked', + '', + ) + } + }) }) async function selectFieldToEdit(