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..b21465114a5 --- /dev/null +++ b/packages/ra-core/src/controller/button/useBulkDeleteController.ts @@ -0,0 +1,117 @@ +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'; + +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?.message, + }, + } + ); + 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..11e6c8f4841 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); @@ -102,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, }, } ); @@ -166,11 +158,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.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 f7b149d78e8..bad9dca5bf1 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,76 +34,52 @@ 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, + 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 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); + const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); + setOpen(true); }; - const handleDialogClose = () => { + const handleDialogClose = (e: React.MouseEvent) => { + e.stopPropagation(); setOpen(false); }; - const handleDelete = e => { - deleteMany(); + 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') { onClick(e); @@ -159,7 +131,7 @@ export const BulkDeleteWithConfirmButton = ( ), }), }} - onConfirm={handleDelete} + onConfirm={handleConfirm} onClose={handleDialogClose} /> @@ -169,27 +141,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..715ce3f7a04 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,20 @@ 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, + Omit< + UseBulkDeleteControllerParams, + 'mutationMode' + > { icon?: React.ReactNode; - mutationOptions?: UseMutationOptions< - RecordType, - MutationOptionsError, - DeleteManyParams - > & { meta?: any }; - successMessage?: string; } const PREFIX = 'RaBulkDeleteWithUndoButton';