From 74f923b6774a1d21bb1be870c86b4f04bd2d84cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 22 Apr 2025 23:38:56 +0200 Subject: [PATCH 01/16] Add InPlaceEditor input --- .../InPlaceEditor/InPlaceEditor.stories.tsx | 190 +++++++++++++++ .../src/input/InPlaceEditor/InPlaceEditor.tsx | 222 ++++++++++++++++++ .../InPlaceEditor/InPlaceEditorContext.ts | 22 ++ 3 files changed, 434 insertions(+) create mode 100644 packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx create mode 100644 packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx create mode 100644 packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditorContext.ts diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx new file mode 100644 index 00000000000..dc97e0babe9 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { createTheme, ThemeProvider, Typography } from '@mui/material'; +import { + ResourceContextProvider, + DataProviderContext, + I18nContextProvider, + ShowBase, + TestMemoryRouter, + NotificationContextProvider, + UndoableMutationsContextProvider, +} from 'ra-core'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { InPlaceEditor } from './InPlaceEditor'; +import { SelectInput } from '../SelectInput'; +import { SelectField, TextField } from '../../field'; +import { Notification } from '../../layout'; + +export default { + title: 'ra-ui-materialui/input/InPlaceEditor', +}; + +const i18nProvider = polyglotI18nProvider(() => englishMessages, 'en'); + +const Wrapper = ({ children, dataProvider }) => ( + + + + + + + + + {children} + + + + + + + + + +); + +export const Basic = ({ + delay, + updateFails, + mutationMode, + notifyOnSuccess, +}) => { + const dataProvider = fakeRestDataProvider( + { + users: [ + { id: 1, name: 'John Doe', age: 25 }, + { id: 2, name: 'Jane Doe', age: 30 }, + ], + }, + process.env.NODE_ENV !== 'test' + ); + const sometimesFailsDataProvider = { + ...dataProvider, + update: async (resource, params) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (updateFails) { + reject(new Error('Server error')); + } else { + resolve(dataProvider.update(resource, params)); + } + }, delay); + }) as any; + }, + }; + + return ( + + Text above + + Text below + + ); +}; + +Basic.args = { + delay: 500, + updateFails: false, + mutationMode: 'pessimistic', + notifyOnSuccess: false, +}; +Basic.argTypes = { + delay: { control: { type: 'number' } }, + updateFails: { control: { type: 'boolean' } }, + mutationMode: { + control: { type: 'select' }, + options: ['optimistic', 'pessimistic', 'undoable'], + }, + notifyOnSuccess: { control: { type: 'boolean' } }, +}; + +export const SX = () => { + const dataProvider = fakeRestDataProvider( + { + users: [{ id: 1, name: 'John Doe', age: 25 }], + }, + process.env.NODE_ENV !== 'test' + ); + return ( + + + + ); +}; + +export const Children = () => { + const dataProvider = fakeRestDataProvider( + { + users: [{ id: 1, name: 'John Doe', age: 25 }], + }, + process.env.NODE_ENV !== 'test' + ); + return ( + + + {' '} + years old + + + ); +}; + +export const Editor = () => { + const dataProvider = fakeRestDataProvider( + { + users: [{ id: 1, name: 'John Doe', type: 'prospect' }], + }, + process.env.NODE_ENV !== 'test' + ); + return ( + + + } + > + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx new file mode 100644 index 00000000000..c21806f4121 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import { useReducer, useRef } from 'react'; +import { + useNotify, + useRecordContext, + useResourceContext, + useTranslate, + useUpdate, + Form, + RecordContextProvider, +} from 'ra-core'; +import isEqual from 'lodash/isEqual'; +import { Box, IconButton, type SxProps } from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; +import CloseIcon from '@mui/icons-material/Close'; + +import { + InPlaceEditorContext, + type InPlaceEditorValue, + type InPlaceEditorAction, +} from './InPlaceEditorContext'; +import { TextInput } from '../TextInput'; +import { TextField } from '../../field'; + +export interface InPlaceEditorProps { + source?: string; + mutationMode?: 'optimistic' | 'pessimistic' | 'undoable'; + cancelOnBlur?: boolean; + notifyOnSuccess?: boolean; + children?: React.ReactNode; + editor?: React.ReactNode; + sx?: SxProps; +} + +export const InPlaceEditor = (props: InPlaceEditorProps) => { + const { + source, + mutationMode, + sx, + cancelOnBlur, + children = source ? ( + + + + ) : null, + editor = source ? ( + + ) : null, + notifyOnSuccess, + } = props; + + if (!source && !children && !editor) { + throw new Error( + 'InPlaceEditor requires either a source prop or children or editor prop' + ); + } + + const submitButtonRef = useRef(null); + + const [state, dispatch] = useReducer< + ( + state: InPlaceEditorValue, + action: InPlaceEditorAction + ) => InPlaceEditorValue + >( + (_, action) => { + switch (action.type) { + case 'edit': + return { state: 'editing' }; + case 'save': + return { state: 'saving', values: action.values }; + case 'error': + case 'success': + case 'cancel': + return { state: 'reading' }; + default: + throw new Error('Unhandled action'); + } + }, + { state: 'reading' } + ); + + const record = useRecordContext(); + const resource = useResourceContext(); + const notify = useNotify(); + const [update] = useUpdate(); + const translate = useTranslate(); + + const handleSave = async values => { + if (!record) { + throw new Error('No record found'); + } + if (isEqual(values, record)) { + dispatch({ type: 'cancel' }); + return; + } + dispatch({ type: 'save', values }); + update( + resource, + { + id: record.id, + data: values, + previousData: record, + }, + { + mutationMode, + onSuccess: () => { + dispatch({ type: 'success' }); + if (mutationMode !== 'undoable' && !notifyOnSuccess) return; + notify(`resources.${resource}.notifications.updated`, { + type: 'info', + messageArgs: { + smart_count: 1, + _: translate('ra.notification.updated', { + smart_count: 1, + }), + }, + undoable: mutationMode === 'undoable', + }); + }, + onError: error => { + notify('ra.notification.http_error', { + type: 'error', + messageArgs: { _: error.message }, + }); + dispatch({ type: 'error', error }); + }, + } + ); + }; + + const handleEdit = () => { + dispatch({ type: 'edit' }); + }; + const handleCancel = () => { + dispatch({ type: 'cancel' }); + }; + const handleBlur = (event: React.FocusEvent) => { + if (event.relatedTarget) { + return; + } + if (cancelOnBlur) { + dispatch({ type: 'cancel' }); + } + if (state.state === 'editing') { + // trigger the submit button click + // to save the changes + (submitButtonRef.current as HTMLButtonElement).click(); + } + }; + + return ( + + {state.state === 'reading' ? ( + + {children} + + ) : state.state === 'editing' ? ( +
+ { + if (event.key === 'Escape') { + handleCancel(); + } + }} + onBlur={handleBlur} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + }} + > + {editor} + + + + + + + +
+ ) : state.state === 'saving' ? ( + + {children} + + ) : ( + '' + )} +
+ ); +}; diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditorContext.ts b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditorContext.ts new file mode 100644 index 00000000000..8ea6df70664 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditorContext.ts @@ -0,0 +1,22 @@ +import { createContext } from 'react'; + +export type InPlaceEditorAction = + | { type: 'edit' } + | { type: 'save'; values: any } + | { type: 'cancel' } + | { type: 'success' } + | { type: 'error'; error: any }; + +export type InPlaceEditorValue = + | { state: 'editing' } + | { state: 'saving'; values: any } + | { state: 'reading' }; + +type InPlaceEditorContextType = { + state: InPlaceEditorValue; + dispatch: (action: InPlaceEditorAction) => void; +}; + +export const InPlaceEditorContext = createContext< + InPlaceEditorContextType | undefined +>(undefined); From 714a7361ba8c2dbf683cc43fb9196cb20be21e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Wed, 23 Apr 2025 12:28:06 +0200 Subject: [PATCH 02/16] Add complex story --- .../InPlaceEditor.Card.stories.tsx | 356 ++++++++++++++++++ .../InPlaceEditor/InPlaceEditor.stories.tsx | 9 +- .../src/input/InPlaceEditor/InPlaceEditor.tsx | 48 ++- 3 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.Card.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.Card.stories.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.Card.stories.tsx new file mode 100644 index 00000000000..55075ba8fb5 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.Card.stories.tsx @@ -0,0 +1,356 @@ +import * as React from 'react'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { + createTheme, + Avatar, + Box, + ThemeProvider, + Card, + Divider, + Stack, + Typography, + IconButton, +} from '@mui/material'; +import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined'; +import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined'; +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined'; +import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; +import { + ResourceContextProvider, + DataProviderContext, + I18nContextProvider, + ShowBase, + TestMemoryRouter, + NotificationContextProvider, + UndoableMutationsContextProvider, +} from 'ra-core'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { InPlaceEditor } from './InPlaceEditor'; +import { SelectInput } from '../SelectInput'; +import { ChipField, SelectField, TextField } from '../../field'; +import { Notification } from '../../layout'; + +export default { + title: 'ra-ui-materialui/input/InPlaceEditor', +}; + +const i18nProvider = polyglotI18nProvider(() => englishMessages, 'en'); + +const Wrapper = ({ children, dataProvider }) => ( + + + + + + + + + {children} + + + + + + + + + +); + +export const Complex = () => { + const dataProvider = fakeRestDataProvider( + { + users: [ + { + id: 1, + name: 'Kevin Malon', + phone: '(+91) 999 564 4837', + email: 'kevinmalon@gmail.com', + leadStatus: 'customer', + access: 'everyone', + }, + ], + }, + process.env.NODE_ENV !== 'test' + ); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Phone + + + + + + + + + + + Email + + + + + + + + + + + Lead Status + + + + } + > + + } + sx={{ + display: 'block', + marginBottom: '5px', + }} + /> + + + + + + + + + Access + + + + } + > + + + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx index dc97e0babe9..cf41504ffd9 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { createTheme, ThemeProvider, Typography } from '@mui/material'; + import { ResourceContextProvider, DataProviderContext, @@ -51,6 +52,7 @@ export const Basic = ({ updateFails, mutationMode, notifyOnSuccess, + showButtons, }) => { const dataProvider = fakeRestDataProvider( { @@ -83,6 +85,7 @@ export const Basic = ({ source="name" mutationMode={mutationMode} notifyOnSuccess={notifyOnSuccess} + showButtons={showButtons} /> Text below @@ -94,6 +97,7 @@ Basic.args = { updateFails: false, mutationMode: 'pessimistic', notifyOnSuccess: false, + showButtons: false, }; Basic.argTypes = { delay: { control: { type: 'number' } }, @@ -103,6 +107,7 @@ Basic.argTypes = { options: ['optimistic', 'pessimistic', 'undoable'], }, notifyOnSuccess: { control: { type: 'boolean' } }, + showButtons: { control: { type: 'boolean' } }, }; export const SX = () => { @@ -154,6 +159,7 @@ export const Editor = () => { ); return ( + Text above { { id: 'customer', name: 'Customer' }, ]} variant="body1" - sx={{ marginTop: '1px' }} + sx={{ marginTop: '1px', marginBottom: '5px' }} component="div" /> + Text below ); }; diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx index c21806f4121..026bbd5f5fd 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx @@ -27,6 +27,7 @@ export interface InPlaceEditorProps { mutationMode?: 'optimistic' | 'pessimistic' | 'undoable'; cancelOnBlur?: boolean; notifyOnSuccess?: boolean; + showButtons?: boolean; children?: React.ReactNode; editor?: React.ReactNode; sx?: SxProps; @@ -39,7 +40,7 @@ export const InPlaceEditor = (props: InPlaceEditorProps) => { sx, cancelOnBlur, children = source ? ( - + { InputProps={{ sx }} /> ) : null, + showButtons, notifyOnSuccess, } = props; @@ -150,14 +152,16 @@ export const InPlaceEditor = (props: InPlaceEditorProps) => { dispatch({ type: 'cancel' }); }; const handleBlur = (event: React.FocusEvent) => { + console.log('here1'); if (event.relatedTarget) { + console.log('here2'); return; } if (cancelOnBlur) { dispatch({ type: 'cancel' }); } if (state.state === 'editing') { - // trigger the submit button click + // trigger the parent form submit // to save the changes (submitButtonRef.current as HTMLButtonElement).click(); } @@ -193,21 +197,31 @@ export const InPlaceEditor = (props: InPlaceEditorProps) => { }} > {editor} - - - - - - + {showButtons ? ( + <> + + + + + + + + ) : ( +