From 0f48f84343ac051d0ab1ad936f69abf4a9058b35 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:59:32 +0200 Subject: [PATCH 1/5] Introduce `useBulkDeleteController` --- .../ra-core/src/controller/button/index.ts | 1 + .../button/useBulkDeleteController.ts | 170 ++++++++++++++++++ .../controller/button/useDeleteController.tsx | 21 +-- .../button/BulkDeleteWithConfirmButton.tsx | 92 ++-------- .../src/button/BulkDeleteWithUndoButton.tsx | 79 +------- 5 files changed, 204 insertions(+), 159 deletions(-) create mode 100644 packages/ra-core/src/controller/button/useBulkDeleteController.ts diff --git a/packages/ra-core/src/controller/button/index.ts b/packages/ra-core/src/controller/button/index.ts index 926e94e5424..ae58aae61ae 100644 --- a/packages/ra-core/src/controller/button/index.ts +++ b/packages/ra-core/src/controller/button/index.ts @@ -3,3 +3,4 @@ import useDeleteWithConfirmController from './useDeleteWithConfirmController'; export { useDeleteWithUndoController, useDeleteWithConfirmController }; export * from './useDeleteController'; +export * from './useBulkDeleteController'; diff --git a/packages/ra-core/src/controller/button/useBulkDeleteController.ts b/packages/ra-core/src/controller/button/useBulkDeleteController.ts new file mode 100644 index 00000000000..de83eb2c367 --- /dev/null +++ b/packages/ra-core/src/controller/button/useBulkDeleteController.ts @@ -0,0 +1,170 @@ +import { useCallback, useMemo } from 'react'; +import { + useDeleteMany, + UseDeleteManyOptions, +} from '../../dataProvider/useDeleteMany'; +import { useRefresh } from '../../dataProvider/useRefresh'; +import { useListContext } from '../list/useListContext'; +import { useNotify } from '../../notification/useNotify'; +import { RaRecord, MutationMode } from '../../types'; +import { useResourceContext } from '../../core/useResourceContext'; +import { useTranslate } from '../../i18n/useTranslate'; + +/** + * Prepare a set of callbacks for a delete button + * + * @example + * const DeleteButton = ({ + * resource, + * record, + * redirect, + * ...rest + * }) => { + * const { + * isPending, + * handleDelete, + * } = useDeleteController({ + * mutationMode: 'pessimistic', + * resource, + * record, + * redirect, + * }); + * + * const [open, setOpen] = useState(false); + * + * return ( + * + * + * handleDelete()} + * onClose={() => setOpen(false)} + * /> + * + * ); + * }; + */ +export const useBulkDeleteController = < + RecordType extends RaRecord = any, + ErrorType = Error, +>( + props: UseBulkDeleteControllerParams +): UseBulkDeleteControllerReturn => { + const { + mutationMode = 'undoable', + mutationOptions = {}, + successMessage, + } = props; + const { meta: mutationMeta, ...otherMutationOptions } = mutationOptions; + const resource = useResourceContext(props); + const notify = useNotify(); + const refresh = useRefresh(); + const translate = useTranslate(); + const { selectedIds, onUnselectItems } = useListContext(); + + const [deleteMany, { isPending }] = useDeleteMany( + resource, + undefined, + { + onSuccess: () => { + notify( + successMessage ?? + `resources.${resource}.notifications.deleted`, + { + type: 'info', + messageArgs: { + smart_count: selectedIds.length, + _: translate('ra.notification.deleted', { + smart_count: selectedIds.length, + }), + }, + undoable: mutationMode === 'undoable', + } + ); + onUnselectItems(); + }, + onError: (error: any) => { + notify( + typeof error === 'string' + ? error + : error.message || 'ra.notification.http_error', + { + type: 'error', + messageArgs: { + _: + typeof error === 'string' + ? error + : error && error.message + ? error.message + : undefined, + }, + } + ); + refresh(); + }, + } + ); + + const handleDelete = useCallback(() => { + deleteMany( + resource, + { + ids: selectedIds, + meta: mutationMeta, + }, + { + mutationMode, + ...otherMutationOptions, + } + ); + }, [ + deleteMany, + mutationMeta, + mutationMode, + otherMutationOptions, + resource, + selectedIds, + ]); + + return useMemo( + () => ({ + isPending, + isLoading: isPending, + handleDelete, + }), + [isPending, handleDelete] + ); +}; + +export interface UseBulkDeleteControllerParams< + RecordType extends RaRecord = any, + MutationOptionsError = unknown, +> { + mutationMode?: MutationMode; + mutationOptions?: UseDeleteManyOptions; + resource?: string; + successMessage?: string; +} + +export interface UseBulkDeleteControllerReturn { + isLoading: boolean; + isPending: boolean; + handleDelete: () => void; +} diff --git a/packages/ra-core/src/controller/button/useDeleteController.tsx b/packages/ra-core/src/controller/button/useDeleteController.tsx index 073f2b5eb01..76a03240041 100644 --- a/packages/ra-core/src/controller/button/useDeleteController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteController.tsx @@ -1,11 +1,10 @@ import { useCallback, useMemo } from 'react'; -import { UseMutationOptions } from '@tanstack/react-query'; -import { useDelete } from '../../dataProvider'; -import { useUnselect } from '../'; +import { useDelete, UseDeleteOptions } from '../../dataProvider'; +import { useRecordContext, useUnselect } from '../'; import { useRedirect, RedirectionSideEffect } from '../../routing'; import { useNotify } from '../../notification'; -import { RaRecord, MutationMode, DeleteParams } from '../../types'; +import { RaRecord, MutationMode } from '../../types'; import { useResourceContext } from '../../core'; import { useTranslate } from '../../i18n'; @@ -14,8 +13,6 @@ import { useTranslate } from '../../i18n'; * * @example * const DeleteButton = ({ - * resource, - * record, * redirect, * ...rest * }) => { @@ -24,8 +21,6 @@ import { useTranslate } from '../../i18n'; * handleDelete, * } = useDeleteController({ * mutationMode: 'pessimistic', - * resource, - * record, * redirect, * }); * @@ -67,13 +62,13 @@ export const useDeleteController = < props: UseDeleteControllerParams ): UseDeleteControllerReturn => { const { - record, redirect: redirectTo = 'list', - mutationMode, + mutationMode = 'undoable', mutationOptions = {}, successMessage, } = props; const { meta: mutationMeta, ...otherMutationOptions } = mutationOptions; + const record = useRecordContext(props); const resource = useResourceContext(props); const notify = useNotify(); const unselect = useUnselect(resource); @@ -166,11 +161,7 @@ export interface UseDeleteControllerParams< MutationOptionsError = unknown, > { mutationMode?: MutationMode; - mutationOptions?: UseMutationOptions< - RecordType, - MutationOptionsError, - DeleteParams - >; + mutationOptions?: UseDeleteOptions; record?: RecordType; redirect?: RedirectionSideEffect; resource?: string; diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index f7b149d78e8..b92c12e206c 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx @@ -8,20 +8,16 @@ import { useThemeProps, } from '@mui/material/styles'; import { - type MutationMode, - useDeleteMany, - useListContext, - useNotify, - useRefresh, useResourceContext, useTranslate, type RaRecord, - type DeleteManyParams, + UseBulkDeleteControllerParams, + useBulkDeleteController, + useListContext, } from 'ra-core'; import { Confirm } from '../layout'; import { Button, type ButtonProps } from './Button'; -import type { UseMutationOptions } from '@tanstack/react-query'; import { humanize, inflect } from 'inflection'; export const BulkDeleteWithConfirmButton = ( @@ -38,64 +34,18 @@ export const BulkDeleteWithConfirmButton = ( icon = defaultIcon, label = 'ra.action.delete', mutationMode = 'pessimistic', - mutationOptions = {}, - successMessage, onClick, ...rest } = props; - const { meta: mutationMeta, ...otherMutationOptions } = mutationOptions; - const { selectedIds, onUnselectItems } = useListContext(); + const { selectedIds } = useListContext(); + const { handleDelete, isPending } = useBulkDeleteController({ + mutationMode, + ...rest, + }); + const [isOpen, setOpen] = useState(false); - const notify = useNotify(); const resource = useResourceContext(props); - const refresh = useRefresh(); const translate = useTranslate(); - const [deleteMany, { isPending }] = useDeleteMany( - resource, - { ids: selectedIds, meta: mutationMeta }, - { - onSuccess: () => { - refresh(); - notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, - { - type: 'info', - messageArgs: { - smart_count: selectedIds.length, - _: translate('ra.notification.deleted', { - smart_count: selectedIds.length, - }), - }, - undoable: mutationMode === 'undoable', - } - ); - onUnselectItems(); - setOpen(false); - }, - onError: (error: Error) => { - notify( - typeof error === 'string' - ? error - : error.message || 'ra.notification.http_error', - { - type: 'error', - messageArgs: { - _: - typeof error === 'string' - ? error - : error && error.message - ? error.message - : undefined, - }, - } - ); - setOpen(false); - }, - mutationMode, - ...otherMutationOptions, - } - ); const handleClick = e => { setOpen(true); @@ -106,8 +56,9 @@ export const BulkDeleteWithConfirmButton = ( setOpen(false); }; - const handleDelete = e => { - deleteMany(); + const handleConfirm = e => { + setOpen(false); + handleDelete(); if (typeof onClick === 'function') { onClick(e); @@ -159,7 +110,7 @@ export const BulkDeleteWithConfirmButton = ( ), }), }} - onConfirm={handleDelete} + onConfirm={handleConfirm} onClose={handleDialogClose} /> @@ -169,27 +120,20 @@ export const BulkDeleteWithConfirmButton = ( const sanitizeRestProps = ({ classes, label, + resource, + successMessage, ...rest -}: Omit< - BulkDeleteWithConfirmButtonProps, - 'resource' | 'icon' | 'mutationMode' ->) => rest; +}: Omit) => rest; export interface BulkDeleteWithConfirmButtonProps< RecordType extends RaRecord = any, MutationOptionsError = unknown, -> extends ButtonProps { +> extends ButtonProps, + UseBulkDeleteControllerParams { confirmContent?: React.ReactNode; confirmTitle?: React.ReactNode; confirmColor?: 'primary' | 'warning'; icon?: React.ReactNode; - mutationMode: MutationMode; - mutationOptions?: UseMutationOptions< - RecordType, - MutationOptionsError, - DeleteManyParams - > & { meta?: any }; - successMessage?: string; } const PREFIX = 'RaBulkDeleteWithConfirmButton'; diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index e8950e0b23a..b7614dbae70 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -6,18 +6,12 @@ import { useThemeProps, } from '@mui/material/styles'; import { - useDeleteMany, - useRefresh, - useNotify, - useResourceContext, - useListContext, type RaRecord, - type DeleteManyParams, - useTranslate, + useBulkDeleteController, + UseBulkDeleteControllerParams, } from 'ra-core'; import { Button, type ButtonProps } from './Button'; -import type { UseMutationOptions } from '@tanstack/react-query'; export const BulkDeleteWithUndoButton = ( inProps: BulkDeleteWithUndoButtonProps @@ -30,64 +24,12 @@ export const BulkDeleteWithUndoButton = ( label = 'ra.action.delete', icon = defaultIcon, onClick, - mutationOptions = {}, - successMessage, ...rest } = props; - const { meta: mutationMeta, ...otherMutationOptions } = mutationOptions; - const { selectedIds, onUnselectItems } = useListContext(); - - const notify = useNotify(); - const resource = useResourceContext(props); - const refresh = useRefresh(); - const translate = useTranslate(); - const [deleteMany, { isPending }] = useDeleteMany(); + const { handleDelete, isPending } = useBulkDeleteController(rest); const handleClick = e => { - deleteMany( - resource, - { ids: selectedIds, meta: mutationMeta }, - { - onSuccess: () => { - notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, - { - type: 'info', - messageArgs: { - smart_count: selectedIds.length, - _: translate('ra.notification.deleted', { - smart_count: selectedIds.length, - }), - }, - undoable: true, - } - ); - onUnselectItems(); - }, - onError: (error: Error) => { - notify( - typeof error === 'string' - ? error - : error.message || 'ra.notification.http_error', - { - type: 'error', - messageArgs: { - _: - typeof error === 'string' - ? error - : error && error.message - ? error.message - : undefined, - }, - } - ); - refresh(); - }, - mutationMode: 'undoable', - ...otherMutationOptions, - } - ); + handleDelete(); if (typeof onClick === 'function') { onClick(e); } @@ -111,20 +53,17 @@ const defaultIcon = ; const sanitizeRestProps = ({ classes, label, + resource, + successMessage, ...rest -}: Omit) => rest; +}: Omit) => rest; export interface BulkDeleteWithUndoButtonProps< RecordType extends RaRecord = any, MutationOptionsError = unknown, -> extends ButtonProps { +> extends ButtonProps, + UseBulkDeleteControllerParams { icon?: React.ReactNode; - mutationOptions?: UseMutationOptions< - RecordType, - MutationOptionsError, - DeleteManyParams - > & { meta?: any }; - successMessage?: string; } const PREFIX = 'RaBulkDeleteWithUndoButton'; From 6c45dfb7fe2011695359648ac7e3f6963af90735 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:11:00 +0200 Subject: [PATCH 2/5] Remove jsdoc [no ci] --- .../button/useBulkDeleteController.ts | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/packages/ra-core/src/controller/button/useBulkDeleteController.ts b/packages/ra-core/src/controller/button/useBulkDeleteController.ts index de83eb2c367..7827378439d 100644 --- a/packages/ra-core/src/controller/button/useBulkDeleteController.ts +++ b/packages/ra-core/src/controller/button/useBulkDeleteController.ts @@ -10,57 +10,6 @@ import { RaRecord, MutationMode } from '../../types'; import { useResourceContext } from '../../core/useResourceContext'; import { useTranslate } from '../../i18n/useTranslate'; -/** - * Prepare a set of callbacks for a delete button - * - * @example - * const DeleteButton = ({ - * resource, - * record, - * redirect, - * ...rest - * }) => { - * const { - * isPending, - * handleDelete, - * } = useDeleteController({ - * mutationMode: 'pessimistic', - * resource, - * record, - * redirect, - * }); - * - * const [open, setOpen] = useState(false); - * - * return ( - * - * - * handleDelete()} - * onClose={() => setOpen(false)} - * /> - * - * ); - * }; - */ export const useBulkDeleteController = < RecordType extends RaRecord = any, ErrorType = Error, From 1c6fa20b5a176e51fe920cc47acf3869a3e1fddd Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:00:53 +0200 Subject: [PATCH 3/5] Improve error handling --- .../src/controller/button/useBulkDeleteController.ts | 6 ++---- .../src/controller/button/useDeleteController.tsx | 9 +++------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/ra-core/src/controller/button/useBulkDeleteController.ts b/packages/ra-core/src/controller/button/useBulkDeleteController.ts index 7827378439d..b21465114a5 100644 --- a/packages/ra-core/src/controller/button/useBulkDeleteController.ts +++ b/packages/ra-core/src/controller/button/useBulkDeleteController.ts @@ -53,16 +53,14 @@ export const useBulkDeleteController = < notify( typeof error === 'string' ? error - : error.message || 'ra.notification.http_error', + : error?.message || 'ra.notification.http_error', { type: 'error', messageArgs: { _: typeof error === 'string' ? error - : error && error.message - ? error.message - : undefined, + : error?.message, }, } ); diff --git a/packages/ra-core/src/controller/button/useDeleteController.tsx b/packages/ra-core/src/controller/button/useDeleteController.tsx index 76a03240041..11e6c8f4841 100644 --- a/packages/ra-core/src/controller/button/useDeleteController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteController.tsx @@ -97,21 +97,18 @@ export const useDeleteController = < record && unselect([record.id]); redirect(redirectTo, resource); }, - onError: error => { + onError: (error: any) => { notify( typeof error === 'string' ? error - : (error as Error)?.message || - 'ra.notification.http_error', + : error?.message || 'ra.notification.http_error', { type: 'error', messageArgs: { _: typeof error === 'string' ? error - : (error as Error)?.message - ? (error as Error).message - : undefined, + : error?.message, }, } ); From 487a25cf4212ea7e403577d4e736b5ebba506e87 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:01:10 +0200 Subject: [PATCH 4/5] Improve BulkDeleteWithUndoButton props type --- .../ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index b7614dbae70..715ce3f7a04 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -62,7 +62,10 @@ export interface BulkDeleteWithUndoButtonProps< RecordType extends RaRecord = any, MutationOptionsError = unknown, > extends ButtonProps, - UseBulkDeleteControllerParams { + Omit< + UseBulkDeleteControllerParams, + 'mutationMode' + > { icon?: React.ReactNode; } From 1819521decb743f34797bfa96094cf26f7a7e217 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:52:00 +0200 Subject: [PATCH 5/5] Improve side effects handling depending on the mutationMode --- .../BulkDeleteWithConfirmButton.stories.tsx | 22 +++++++++++-- .../button/BulkDeleteWithConfirmButton.tsx | 31 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.stories.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.stories.tsx index 6b0217a1dc0..a6cf853aae3 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.stories.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.stories.tsx @@ -3,6 +3,7 @@ import { CoreAdminContext, testDataProvider, ListContextProvider, + MutationMode, } from 'ra-core'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -13,7 +14,11 @@ export default { title: 'ra-ui-materialui/button/BulkDeleteWithConfirmButton' }; const theme = createTheme(); -export const Default = () => { +export const Default = ({ + mutationMode = 'pessimistic', +}: { + mutationMode?: MutationMode; +}) => { const dataProvider = testDataProvider({ deleteMany: async () => ({ data: [{ id: 123 }] as any }), }); @@ -31,7 +36,7 @@ export const Default = () => { > @@ -39,3 +44,16 @@ export const Default = () => { ); }; + +Default.args = { + mutationMode: 'pessimistic', +}; + +Default.argTypes = { + mutationMode: { + options: ['pessimistic', 'optimistic', 'undoable'], + control: { + type: 'select', + }, + }, +}; diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index b92c12e206c..bad9dca5bf1 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx @@ -41,23 +41,44 @@ export const BulkDeleteWithConfirmButton = ( const { handleDelete, isPending } = useBulkDeleteController({ mutationMode, ...rest, + mutationOptions: { + ...rest.mutationOptions, + onSettled(data, error, variables, context) { + // In pessimistic mode, we wait for the mutation to be completed (either successfully or with an error) before closing + if (mutationMode === 'pessimistic') { + setOpen(false); + } + rest.mutationOptions?.onSettled?.( + data, + error, + variables, + context + ); + }, + }, }); const [isOpen, setOpen] = useState(false); const resource = useResourceContext(props); const translate = useTranslate(); - const handleClick = e => { - setOpen(true); + const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); + setOpen(true); }; - const handleDialogClose = () => { + const handleDialogClose = (e: React.MouseEvent) => { + e.stopPropagation(); setOpen(false); }; - const handleConfirm = e => { - setOpen(false); + const handleConfirm = (e: React.MouseEvent) => { + e.stopPropagation(); + // We close the dialog immediately here for optimistic/undoable modes instead of in onSuccess/onError + // to avoid reimplementing the default side effects + if (mutationMode !== 'pessimistic') { + setOpen(false); + } handleDelete(); if (typeof onClick === 'function') {