diff --git a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx
index d3cf7c1cb83..37ed23469b4 100644
--- a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx
+++ b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx
@@ -1,7 +1,8 @@
import * as React from 'react';
import expect from 'expect';
-import { render, waitFor } from '@testing-library/react';
-import { Basic } from './RichTextInput.stories';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Basic, EditorRecreation } from './RichTextInput.stories';
describe('', () => {
it('should update its content when fields value changes and add a trailing break to it', async () => {
@@ -23,4 +24,22 @@ describe('', () => {
);
});
});
+
+ it('should update its content when the editor is recreated while the field value changes', async () => {
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual(
+ '
This post is a draft, you can edit it.
'
+ );
+ });
+
+ await userEvent.click(screen.getByText('Switch post'));
+
+ await waitFor(() => {
+ expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual(
+ 'This post is published, it is read-only.
'
+ );
+ });
+ });
});
diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx
index e01b3b673ec..e1a2606dd3b 100644
--- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx
+++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx
@@ -17,7 +17,7 @@ import {
Toolbar as RAToolbar,
SaveButton,
} from 'ra-ui-materialui';
-import { useWatch } from 'react-hook-form';
+import { useFormContext, useWatch } from 'react-hook-form';
import fakeRestDataProvider from 'ra-data-fakerest';
import { Routes, Route } from 'react-router-dom';
import Mention from '@tiptap/extension-mention';
@@ -112,6 +112,65 @@ export const ReadOnly = (props: Partial) => (
);
+const postsToSwitch = [
+ { body: 'This post is a draft, you can edit it.
', readOnly: false },
+ { body: 'This post is published, it is read-only.
', readOnly: true },
+];
+
+const SwitchPostButton = ({
+ postIndex,
+ onSwitch,
+}: {
+ postIndex: number;
+ onSwitch: (index: number) => void;
+}) => {
+ const { setValue } = useFormContext();
+ return (
+
+ );
+};
+
+/**
+ * Changing the value and the readOnly prop at the same time recreates the editor
+ * while the content sync effect still references the previous (destroyed) one.
+ * Clicking on "Switch post" used to throw
+ * "Cannot read properties of null (reading 'commands')".
+ */
+export const EditorRecreation = (props: Partial) => {
+ const [postIndex, setPostIndex] = React.useState(0);
+ return (
+
+
+
+ {}}
+ {...props}
+ >
+
+
+
+
+
+
+
+ );
+};
+
export const Small = (props: Partial) => (
diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx
index 3688d686274..9b7b1c9a3c9 100644
--- a/packages/ra-input-rich-text/src/RichTextInput.tsx
+++ b/packages/ra-input-rich-text/src/RichTextInput.tsx
@@ -114,7 +114,12 @@ export const RichTextInput = (props: RichTextInputProps) => {
const { error, invalid, isTouched } = fieldState;
useEffect(() => {
- if (!editor) return;
+ // Tiptap v3's Editor.destroy() sets commandManager to null, and the
+ // `commands` getter dereferences it. When the editor is recreated (e.g.
+ // readOnly/disabled/editorOptions/id change), this passive effect can run
+ // against the just-destroyed instance, throwing "Cannot read properties of
+ // null (reading 'commands')". Bail out on destroyed editors as well.
+ if (!editor || editor.isDestroyed) return;
const { from, to } = editor.state.selection;
diff --git a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx
index e92d3686e26..ecc219ea12a 100644
--- a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx
+++ b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { major as muiMajor } from '@mui/material';
import {
ConsecutiveNotifications,
@@ -8,6 +9,29 @@ import {
} from './Notification.stories';
describe('', () => {
+ (muiMajor >= 6 ? it : it.skip)(
+ 'should confirm an undoable mutation only once when its notification exits with MUI 6+',
+ async () => {
+ const deleteOne = jest
+ .fn()
+ .mockImplementation((_resource, { id }) =>
+ Promise.resolve({ data: { id } })
+ );
+ const dataProvider = { delete: deleteOne } as any;
+ const { container } = render(
+
+ );
+
+ (await screen.findByText('Delete post 1')).click();
+ await screen.findByText('Post 1 deleted');
+ fireEvent.click(container);
+
+ await waitFor(() => expect(deleteOne).toHaveBeenCalled());
+ expect(deleteOne).toHaveBeenCalledTimes(1);
+ expect(deleteOne).toHaveBeenCalledWith('posts', { id: 1 });
+ }
+ );
+
it('should confirm the first undoable notification when a second one starts', async () => {
const deleteOne = jest
.fn()
diff --git a/packages/ra-ui-materialui/src/layout/Notification.tsx b/packages/ra-ui-materialui/src/layout/Notification.tsx
index 327bd1a1434..a7ab8d9409d 100644
--- a/packages/ra-ui-materialui/src/layout/Notification.tsx
+++ b/packages/ra-ui-materialui/src/layout/Notification.tsx
@@ -168,8 +168,12 @@ export const Notification = (inProps: NotificationProps) => {
: autoHideDurationFromMessage ?? undefined
}
disableWindowBlurListener={undoable}
- TransitionProps={transitionProps}
- ContentProps={contentProps}
+ {...(muiMajor < 6
+ ? {
+ TransitionProps: transitionProps,
+ ContentProps: contentProps,
+ }
+ : {})}
onClose={handleRequestClose}
action={
undoable ? (
diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
index 0277f304871..837c04a8f59 100644
--- a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
+++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
@@ -1,5 +1,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
import * as React from 'react';
+import {
+ ResourceContextProvider,
+ TestMemoryRouter,
+ type AuthProvider,
+} from 'ra-core';
import {
Basic,
Columns,
@@ -12,6 +18,8 @@ import {
StandaloneDynamic,
StandaloneStatic,
} from './DataTable.stories';
+import { DataTable } from './DataTable';
+import { AdminContext } from '../../AdminContext';
describe('DataTable', () => {
it('should render one row per record in the list', async () => {
@@ -218,6 +226,71 @@ describe('DataTable', () => {
});
});
describe('bulkActionButtons', () => {
+ it('should not enable delete permission queries without an auth provider', async () => {
+ const queryClient = new QueryClient();
+
+ render(
+
+
+
+ >}
+ data={[{ id: 1 }]}
+ isPending={false}
+ onSelect={jest.fn()}
+ onToggleItem={jest.fn()}
+ selectedIds={[]}
+ total={1}
+ >
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ const [query] = queryClient.getQueryCache().findAll({
+ queryKey: ['auth', 'canAccess'],
+ });
+ expect(query.state.fetchStatus).toBe('idle');
+ expect(query.state.data).toBeUndefined();
+ });
+ });
+
+ it('should not check delete permissions when bulk actions are disabled', () => {
+ const authProvider: AuthProvider = {
+ canAccess: jest.fn().mockResolvedValue(true),
+ checkAuth: jest.fn().mockResolvedValue(undefined),
+ checkError: jest.fn().mockResolvedValue(undefined),
+ getPermissions: jest.fn().mockResolvedValue(undefined),
+ login: jest.fn().mockResolvedValue(undefined),
+ logout: jest.fn().mockResolvedValue(undefined),
+ };
+
+ render(
+
+
+
+
+
+
+
+
+
+ );
+
+ expect(authProvider.canAccess).not.toHaveBeenCalled();
+ });
+
it('should reveal bulk delete button by default on row selection', async () => {
render();
const checkboxes = await screen.findAllByRole('checkbox');
diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx
index 8ccbfdf3927..35ce814a0f5 100644
--- a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx
+++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx
@@ -139,6 +139,7 @@ export const DataTable = React.forwardRef(function DataTable<
const { canAccess: canDelete } = useCanAccess({
resource: resourceFromContext,
action: 'delete',
+ ...(props.bulkActionButtons === false ? { enabled: false } : {}),
});
const {
diff --git a/yarn.lock b/yarn.lock
index ffafede1b67..89d51b2478e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14533,9 +14533,9 @@ __metadata:
linkType: hard
"ip-address@npm:^10.0.1":
- version: 10.2.0
- resolution: "ip-address@npm:10.2.0"
- checksum: 5a00aada6e922c9c69dfc800ed5d0fa3348675ebdeed0e1575f503f27ca385b5f534363c9af7ad1daf64c1f1409388cdd3cc2e9b9b0fe1c924a431378d55075a
+ version: 10.4.0
+ resolution: "ip-address@npm:10.4.0"
+ checksum: d7b0bd2624fd861afbae6e49036a9b56f9506eaff7ff38592b7b4492dd5c272b53f5356dc8cc019e318acf29a96c90a3d1af1df761960267d23efa96858270fa
languageName: node
linkType: hard