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') {