diff --git a/docs/Forms.md b/docs/Forms.md index 80bb9612fdd..2820544ae8d 100644 --- a/docs/Forms.md +++ b/docs/Forms.md @@ -770,3 +770,39 @@ Users often need to edit data from several resources in the same form. React-adm Your browser does not support the video tag. + +## Edit In Place + +Instead of asking users to fill a form to edit a record, you can let them edit the record straight from the list or show view. [The `` component](./InPlaceEditor.md) uses a `` in read mode, and a `` in edition mode. It is useful for quick edits without navigating to a separate edit page. + + + +{% raw %} +```tsx +import { Show, InPlaceEditor } from 'react-admin'; +import { Stack, Box, Typography } from '@mui/material'; + +const CustomerShow = () => ( + + + + + + Phone + + + + Email + + + ... + + +); +``` +{% endraw %} + +Check out [the `` documentation](./InPlaceEditor.md) for more details. diff --git a/docs/InPlaceEditor.md b/docs/InPlaceEditor.md new file mode 100644 index 00000000000..64fe5a689f7 --- /dev/null +++ b/docs/InPlaceEditor.md @@ -0,0 +1,296 @@ +--- +layout: default +title: "The InPlaceEditor Component" +--- + +# `` + +`` renders a field from the current record. On click, it switches to an editable state, allowing the user to change the value directly. + + + +Use this component to let users edit parts of a record directly in the list or detail view. It is useful for quick edits without navigating to a separate edit page. + +The field changes color on hover, to indicate that it is editable. The user can cancel the edit by pressing Escape. The field is saved automatically when the user clicks outside of it or presses Enter. While it is being saved, the field is disabled and a loading spinner is shown. If the save fails, an error message is displayed and the original value is restored. + +## Usage + +Use `` inside a `RecordContext` (e.g., under `` or ``) and pass it a `source` prop to specify which field to edit. The component will render the field with a `` and let the user edit it with a `` + +{% raw %} +```tsx +import { Show, InPlaceEditor } from 'react-admin'; +import { Stack, Box, Typography } from '@mui/material'; +import { AvatarField, CustomerActions } from './components'; + +const CustomerShow = () => ( + + + + + + Phone + + + + Email + + + ... + + +); +``` +{% endraw %} + +**Note**: `` creates a `
`, so it cannot be used inside an existing form (e.g., inside a `` or ``). + +Instead of using the `source` prop, you can also specify the component to render in read mode with the `children` prop, and the component to render in edit mode with the `editor` prop. In general, you will need to tweak the styles of both components to make them look good together. + + + + +{% raw %} +```tsx +const choices = [ + { id: 'everyone', name: 'Everyone' }, + { id: 'just_me', name: 'Just me' }, + { id: 'sales', name: 'Sales' }, +]; + +// ... + + } +> + + +``` +{% endraw %} + +## Props + +| Prop | Required | Type | Default | Description | +| ------------ | -------- | --------- | ------- | -------------------------------------------------------------------- | +| `cancelOnBlur` | Optional | `boolean` | `false` | Whether to cancel the edit when the field loses focus. | +| `children` | Optional | `ReactNode` | | The component to render in read mode. | +| `editor` | Optional | `ReactNode` | | The component to render in edit mode. | +| `mutationMode` | Optional | `string` | `pessimistic` | The mutation mode to use when saving the record. | +| `mutationOptions` | Optional | `object` | | The options to pass to the `useUpdate` hook. | +| `notifyOnSuccess` | Optional | `boolean` | `false` | Whether to show a notification on successful save. | +| `resource` | Optional | `string` | | The name of the resource. | +| `showButtons` | Optional | `boolean` | `false` | Whether to show the save and cancel buttons. | +| `source` | Optional | `string` | | The name of the field to edit. | +| `sx` | Optional | `SxProps` | | The styles to apply to the component. | + +## `cancelOnBlur` + +By default, when the user clicks outside of the field in edit mode, it saves the current value. If `cancelOnBlur` is set to true, the edit will be canceled instead and the initial value will be restored. + +```tsx + +``` + +## `children` + +The component to render in read mode. By default, it's a `` using the `source` prop. + +You can use any [field component](./Fields.md) instead, as it renders in a `RecordContext`. + +![InPlaceEditor children](./img/InPlaceEditorChildren.png) + +For example, to render a `` in read mode, you can use the following code: + +{% raw %} +```tsx + + + } + sx={{ + display: 'block', + marginBottom: '3px', + marginTop: '2px', + }} + /> + +``` +{% endraw %} + +## `editor` + +The component to render in edit mode. By default, it's a `` using the `source` prop. + +You can use any [input component](./Input.md) instead, as it renders in a ``. You will probably need to tweak the input variant, margin and style so that it matches the style of the read mode component. + + + +For example, to use a `` in edit mode, you can use the following code: + +{% raw %} +```tsx + + } +> + // ... + +``` +{% endraw %} + +## `mutationMode` + +The mutation mode to use when saving the record. By default, it is set to `pessimistic`, which means that the record is saved immediately when the user clicks outside of the field or presses Enter. + +You can use any of the following values: + +- `pessimistic`: On save, the field is dimmed to show the saving state. If the server returns an error, the UI is reverted to the previous state. +- `optimistic`: The UI is updated immediately with the new value, without waiting for the server response. If the server returns an error, the UI is reverted to the previous state. +- `undoable`: The record is saved immediately, but the user can undo the operation by clicking on the undo button in the notification. This must be used in conjunction with the `notifyOnSuccess` prop. + +```tsx + +``` + +## `mutationOptions` + +If you need to pass options to the `useUpdate` hook, you can use this prop. + +This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.update()` call. + +{% raw %} +```tsx + +``` +{% endraw %} + +## `notifyOnSuccess` + +By default, the component does not show a notification when the record is saved. If you want to show a notification on successful save, set this prop to `true`. + +![InPlaceEditor notifyOnSuccess](./img/InPlaceEditorNotifyOnSuccess.png) + +```tsx + +``` + +## `resource` + +The name of the resource. By default, it is set to the current resource in the `ResourceContext`. You can use this prop to override the resource name. + +```tsx + +``` + +## `showButtons` + +By default, the component does not show the save and cancel buttons. If you want to show them, set this prop to `true`. + +![InPlaceEditor showButtons](./img/InPlaceEditorShowButtons.png) + +```tsx + +``` + +## `source` + +The name of the field to edit. You must set this prop, unless you define the `children` and `editor` props. + +```tsx + +``` + +## `sx` + +The styles to apply to the component. Use it to alter the default styles of the reading, editing, and saving modes. + +{% raw %} +```tsx + +``` +{% endraw %} + +You can use the `sx` prop to apply styles to the read mode, edit mode and saving mode. The following classes are available: + +- `& .RaInPlaceEditor-reading`: The read mode. +- `& .RaInPlaceEditor-editing`: The editing mode. +- `& .RaInPlaceEditor-saving`: The saving mode. \ No newline at end of file diff --git a/docs/Reference.md b/docs/Reference.md index 010c1c6c134..dc489b99fc4 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -106,6 +106,7 @@ title: "Index" * [``](./ImageField.md) * [``](./ImageInput.md) * [``](./ImageInput.md#imageinput) +* [``](./InPlaceEditor.md) * [``](./Configurable.md#inspectorbutton) **- L -** diff --git a/docs/TextField.md b/docs/TextField.md index 2aecc57e4fc..636eee45aa8 100644 --- a/docs/TextField.md +++ b/docs/TextField.md @@ -58,3 +58,43 @@ import { FunctionField } from 'react-admin'; render={record => `${record.first_name} ${record.last_name}`} />; ``` + +## Edit In Place + +In addition to rendering a field value, you may want to allow users to edit that value. You can redirect the user to an `` page, or you can use the [``](./InPlaceEditor.md) component to edit the value directly in the list or show view. + + + + + +`` renders a `` by default, and turns into a `` when the user clicks on it. It is useful for quick edits without navigating to a separate edit page. + +{% raw %} +```tsx +import { Show, InPlaceEditor } from 'react-admin'; +import { Stack, Box, Typography } from '@mui/material'; + +const CustomerShow = () => ( + + + + + + Phone + + + + Email + + + ... + + +); +``` +{% endraw %} + +Check out [the `` documentation](./InPlaceEditor.md) for more details. diff --git a/docs/TextInput.md b/docs/TextInput.md index 30f0d041323..cacd9ec4f77 100644 --- a/docs/TextInput.md +++ b/docs/TextInput.md @@ -101,6 +101,42 @@ export const PostEdit = () => ( See [the `` documentation](./RichTextInput.md) for more details. +## Edit In Place + +Instead of using a `` in a form, you can use an `` to edit the value directly in the list or the show view. This is useful for quick edits without having to open a form. + + + +{% raw %} +```tsx +import { Show, InPlaceEditor } from 'react-admin'; +import { Stack, Box, Typography } from '@mui/material'; + +const CustomerShow = () => ( + + + + + + Phone + + + + Email + + + ... + + +); +``` +{% endraw %} + +Check out [the `` documentation](./InPlaceEditor.md) for more details. + ## Predictive Text Input An alternative to `` is [``](./PredictiveTextInput.md), which suggests completion for the input value, using your favorite AI backend. Users can accept the completion by pressing the `Tab` key. It's like Intellisense or Copilot for your forms. diff --git a/docs/img/InPlaceEditor.mp4 b/docs/img/InPlaceEditor.mp4 new file mode 100644 index 00000000000..f7544e70bdc Binary files /dev/null and b/docs/img/InPlaceEditor.mp4 differ diff --git a/docs/img/InPlaceEditorChildren.png b/docs/img/InPlaceEditorChildren.png new file mode 100644 index 00000000000..419324f7e98 Binary files /dev/null and b/docs/img/InPlaceEditorChildren.png differ diff --git a/docs/img/InPlaceEditorField.mp4 b/docs/img/InPlaceEditorField.mp4 new file mode 100644 index 00000000000..3b219bc4f8d Binary files /dev/null and b/docs/img/InPlaceEditorField.mp4 differ diff --git a/docs/img/InPlaceEditorNotifyOnSuccess.png b/docs/img/InPlaceEditorNotifyOnSuccess.png new file mode 100644 index 00000000000..c847404c598 Binary files /dev/null and b/docs/img/InPlaceEditorNotifyOnSuccess.png differ diff --git a/docs/img/InPlaceEditorShowButtons.png b/docs/img/InPlaceEditorShowButtons.png new file mode 100644 index 00000000000..799959d529c Binary files /dev/null and b/docs/img/InPlaceEditorShowButtons.png differ diff --git a/docs/navigation.html b/docs/navigation.html index 4975da3623e..8099a396af1 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -210,6 +210,7 @@
  • <DualListInput>
  • <FileInput>
  • <ImageInput>
  • +
  • <InPlaceEditor>
  • <MarkdownInput>
  • <NullableBooleanInput>
  • <NumberInput>
  • diff --git a/examples/simple/src/posts/PostShow.tsx b/examples/simple/src/posts/PostShow.tsx index 718477d4f92..e3e96c84656 100644 --- a/examples/simple/src/posts/PostShow.tsx +++ b/examples/simple/src/posts/PostShow.tsx @@ -7,6 +7,7 @@ import { DataTable, DateField, EditButton, + InPlaceEditor, NumberField, ReferenceArrayField, ReferenceManyField, @@ -93,7 +94,20 @@ const PostShow = () => { /> - + 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', + 500 + ); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Phone + + + + + + + + + + + Email + + + + + + + + + + + Lead Status + + + + } + > + + } + sx={{ + display: 'block', + marginBottom: '3px', + marginTop: '2px', + }} + /> + + + + + + + + + Access + + + + } + > + + + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx new file mode 100644 index 00000000000..de4cc51d302 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { Basic } from './InPlaceEditor.stories'; + +describe('InPlaceEditor', () => { + it('should render the field value on mount', async () => { + render(); + await screen.findByText('John Doe'); + }); + it('should reveal an input on click', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + await screen.findByDisplayValue('John Doe'); + }); + it('should let the user change the value', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + fireEvent.blur(input); + await screen.findByText('Jane Doe'); + }); + it('should revert to the previous version on error', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + fireEvent.blur(input); + await screen.findByText('Jane Doe'); + await screen.findByText('John Doe'); + }); + describe('notifyOnSuccess', () => { + it('should show a notification on success', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + fireEvent.blur(input); + await screen.findByText('Element updated'); + }); + }); + describe('showButtons', () => { + it('should render save and cancel buttons', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + await screen.findByLabelText('Save'); + await screen.findByLabelText('Cancel'); + }); + }); +}); 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..19ad352c879 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx @@ -0,0 +1,383 @@ +import * as React from 'react'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { createTheme, ThemeProvider, Typography } from '@mui/material'; + +import { + ResourceContextProvider, + DataProviderContext, + I18nContextProvider, + ListBase, + ShowBase, + TestMemoryRouter, + NotificationContextProvider, + UndoableMutationsContextProvider, + required, +} 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 { NumberInput } from '../NumberInput'; +import { NumberField, SelectField, TextField } from '../../field'; +import { Notification } from '../../layout'; +import { DataTable } from '../../list/datatable'; + +export default { + title: 'ra-ui-materialui/input/InPlaceEditor', +}; + +const i18nProvider = polyglotI18nProvider(() => englishMessages, 'en'); + +const Wrapper = ({ + children, + dataProvider = fakeRestDataProvider( + { users: [{ id: 1, name: 'John Doe', age: 25, type: 'customer' }] }, + process.env.NODE_ENV !== 'test', + 500 + ), +}) => ( + + + + + + + + + {children} + + + + + + + + + +); + +export const Basic = ({ + delay, + updateFails, + mutationMode, + notifyOnSuccess, + showButtons, +}: { + delay?: number; + updateFails?: boolean; + mutationMode?: 'optimistic' | 'pessimistic' | 'undoable'; + notifyOnSuccess?: boolean; + showButtons?: boolean; +}) => { + const dataProvider = fakeRestDataProvider( + { users: [{ id: 1, name: 'John Doe', age: 25, type: 'customer' }] }, + 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, + showButtons: false, +}; +Basic.argTypes = { + delay: { control: { type: 'number' } }, + updateFails: { control: { type: 'boolean' } }, + mutationMode: { + control: { type: 'select' }, + options: ['optimistic', 'pessimistic', 'undoable'], + }, + notifyOnSuccess: { control: { type: 'boolean' } }, + showButtons: { control: { type: 'boolean' } }, +}; + +export const Children = () => ( + + + {' '} + years old + + +); + +export const Editor = () => ( + + Text above + + } + > + + + Text below + +); + +export const CancelOnBlur = () => ( + + + +); + +export const MutationMode = () => ( + + + +); + +export const NotifyOnSuccess = () => ( + + + +); + +export const ShowButtons = () => ( + + + +); + +export const MutationOptions = () => ( + + + +); + +export const SX = () => ( + + + +); + +export const EditableDataTable = () => ( + + + + + + + + + + + + + + + + + } + > + + + + + + } + > + + + + + + + + + + + + + + +); 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..53ce1d9130f --- /dev/null +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx @@ -0,0 +1,305 @@ +import * as React from 'react'; +import { useReducer, useRef } from 'react'; +import { + useNotify, + useRecordContext, + useResourceContext, + useTranslate, + useUpdate, + Form, + RecordContextProvider, + type UseUpdateOptions, + type RaRecord, +} from 'ra-core'; +import isEqual from 'lodash/isEqual'; +import { styled } from '@mui/material/styles'; +import { Box, IconButton, type SxProps } from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; +import CloseIcon from '@mui/icons-material/Close'; + +import { TextInput } from '../TextInput'; +import { TextField } from '../../field'; + +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' }; + +export interface InPlaceEditorProps< + RecordType extends RaRecord = any, + ErrorType = Error, +> { + source?: string; + mutationMode?: 'optimistic' | 'pessimistic' | 'undoable'; + mutationOptions?: UseUpdateOptions; + cancelOnBlur?: boolean; + notifyOnSuccess?: boolean; + resource?: string; + showButtons?: boolean; + children?: React.ReactNode; + editor?: React.ReactNode; + sx?: SxProps; +} + +/** + * Renders a value, and on click it turns into an editable field. + * + * The editable field is rendered inside a Form component, so InPlaceEditor + * cannot be used inside another Form component. + */ +export const InPlaceEditor = < + RecordType extends RaRecord = any, + ErrorType extends Error = Error, +>( + props: InPlaceEditorProps +) => { + const { + source, + mutationMode, + mutationOptions = {}, + sx, + cancelOnBlur, + children = source ? ( + + ) : null, + editor = source ? ( + + ) : null, + showButtons, + notifyOnSuccess, + } = props; + + if (!source && !children && !editor) { + throw new Error( + 'InPlaceEditor requires either a source prop or children or editor prop' + ); + } + if (mutationMode === 'undoable' && !notifyOnSuccess) { + throw new Error( + 'InPlaceEditor requires notifyOnSuccess to be true when mutationMode is undoable' + ); + } + + 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(props); + const notify = useNotify(); + const translate = useTranslate(); + const [update] = useUpdate(); + + const { + meta: mutationMeta, + 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 }); + }, + ...otherMutationOptions + } = mutationOptions; + + 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, + meta: mutationMeta, + }, + { + onSuccess, + onError, + mutationMode, + ...otherMutationOptions, + } + ); + }; + + const handleEdit = () => { + dispatch({ type: 'edit' }); + }; + const handleCancel = () => { + dispatch({ type: 'cancel' }); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + dispatch({ type: 'cancel' }); + } + }; + + const handleBlur = (event: React.FocusEvent) => { + if (event.relatedTarget) { + return; + } + if (cancelOnBlur) { + dispatch({ type: 'cancel' }); + return; + } + if (state.state === 'editing') { + // trigger the parent form submit + // to save the changes + (submitButtonRef.current as HTMLButtonElement).click(); + } + }; + + const renderContent = () => { + switch (state.state) { + case 'reading': + return ( + + {children} + + ); + case 'editing': + return ( + + + {editor} + {showButtons ? ( + <> + + + + + + + + ) : ( +