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