diff --git a/docs/ArrayInput.md b/docs/ArrayInput.md index 1dc23c2596..8d048781fb 100644 --- a/docs/ArrayInput.md +++ b/docs/ArrayInput.md @@ -38,7 +38,6 @@ To edit arrays of data embedded inside a record, `` creates a list o } ``` -**Tip**: If you need to edit an array of *strings*, like a list of email addresses or a list of tags, you should use a [``](./TextArrayInput.md) instead. `` expects a single child, which must be a *form iterator* component. A form iterator is a component rendering a field array (the object returned by react-hook-form's [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray)). For instance, [the `` component](./SimpleFormIterator.md) displays an array of react-admin Inputs in an unordered list (`
    `), one sub-form by list item (`
  • `). It also provides controls for adding and removing a sub-record. diff --git a/docs/AutocompleteArrayInput.md b/docs/AutocompleteArrayInput.md index 2ae96fc221..005b0d464a 100644 --- a/docs/AutocompleteArrayInput.md +++ b/docs/AutocompleteArrayInput.md @@ -14,11 +14,11 @@ It renders using Material UI [Autocomplete](https://mui.com/material-ui/react-au Your browser does not support the video tag. + This input allows editing values that are arrays of scalar values, e.g. `[123, 456]`. **Tip**: React-admin includes other components allowing the edition of such values: -- [``](./TextArrayInput.md) lets you edit an array of strings - [``](./SelectArrayInput.md) renders a dropdown list of choices - [``](./CheckboxGroupInput.md) renders a list of checkbox options - [``](./DualListInput.md) renders a list of choices that can be moved from one list to another diff --git a/docs/AutocompleteInput.md b/docs/AutocompleteInput.md index 387c7fa8fe..02c5303aaa 100644 --- a/docs/AutocompleteInput.md +++ b/docs/AutocompleteInput.md @@ -14,6 +14,7 @@ It renders using [Material UI's ``](https://mui.com/material-ui/re Your browser does not support the video tag. + This input allows editing record fields that are scalar values, e.g. `123`, `'admin'`, etc. ## Usage diff --git a/docs/CheckboxGroupInput.md b/docs/CheckboxGroupInput.md index 4a645b8308..9b6d99d073 100644 --- a/docs/CheckboxGroupInput.md +++ b/docs/CheckboxGroupInput.md @@ -18,7 +18,6 @@ This input allows editing values that are arrays of scalar values, e.g. `[123, 4 **Tip**: React-admin includes other components allowing the edition of such values: -- [``](./TextArrayInput.md) lets you edit an array of strings - [``](./SelectArrayInput.md) renders a dropdown list of choices - [``](./AutocompleteArrayInput.md) renders an autocomplete input of choices - [``](./DualListInput.md) renders a list of choices that can be moved from one list to another diff --git a/docs/Community.md b/docs/Community.md index 6aa7fb0554..6093c73c45 100644 --- a/docs/Community.md +++ b/docs/Community.md @@ -45,7 +45,7 @@ On our [Youtube channel](https://www.youtube.com/@react-admin), you can find som
    diff --git a/docs/Create.md b/docs/Create.md index ff04dd6da4..15e0b7a0cc 100644 --- a/docs/Create.md +++ b/docs/Create.md @@ -441,7 +441,7 @@ You can do the same for error notifications, by passing a custom `onError` call You sometimes need to pre-populate a record based on a *related* record. For instance, to create a comment related to an existing post. -By default, the `` view starts with an empty `record`. However, if the `location` object (injected by [react-router-dom](https://reactrouter.com/6.28.0/start/concepts#locations)) contains a `record` in its `state`, the `` view uses that `record` instead of the empty object. That's how the `` works under the hood. +By default, the `` view starts with an empty `record`. However, if the `location` object (injected by [react-router-dom](https://reacttraining.com/react-router/web/api/location)) contains a `record` in its `state`, the `` view uses that `record` instead of the empty object. That's how the `` works under the hood. That means that if you want to create a link to a creation form, presetting *some* values, all you have to do is to set the `state` prop of the ``: diff --git a/docs/DualListInput.md b/docs/DualListInput.md index e4a7ea4f71..d19c6bcecf 100644 --- a/docs/DualListInput.md +++ b/docs/DualListInput.md @@ -16,7 +16,6 @@ This input allows editing values that are arrays of scalar values, e.g. `[123, 4 **Tip**: React-admin includes other components allowing the edition of such values: -- [``](./TextArrayInput.md) lets you edit an array of strings - [``](./AutocompleteArrayInput.md) renders an Autocomplete - [``](./SelectArrayInput.md) renders a dropdown list of choices - [``](./CheckboxGroupInput.md) renders a list of checkbox options diff --git a/docs/Edit.md b/docs/Edit.md index 7b44f9906c..56b1c9801f 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -787,59 +787,6 @@ You can do the same for error notifications, by passing a custom `onError` call **Tip**: The notification message will be translated. -## Prefilling the Form - -You sometimes need to pre-populate the form changes to a record. For instance, to revert a record to a previous version, or to make some changes while letting users modify others fields as well. - -By default, the `` view starts with the current `record`. However, if the `location` object (injected by [react-router-dom](https://reactrouter.com/6.28.0/start/concepts#locations)) contains a `record` in its `state`, the `` view uses that `record` to prefill the form. - -That means that if you want to create a link to an edition view, modifying immediately *some* values, all you have to do is to set the `state` prop of the ``: - -{% raw %} -```jsx -import * as React from 'react'; -import { EditButton, Datagrid, List } from 'react-admin'; - -const ApproveButton = () => { - return ( - - ); -}; - -export default PostList = () => ( - - - ... - - - -) -``` -{% endraw %} - -**Tip**: The `` component also watches the "source" parameter of `location.search` (the query string in the URL) in addition to `location.state` (a cross-page message hidden in the router memory). So the `ApproveButton` could also be written as: - -{% raw %} -```jsx -import * as React from 'react'; -import { EditButton } from 'react-admin'; - -const ApproveButton = () => { - return ( - - ); -}; -``` -{% endraw %} - -Should you use the location `state` or the location `search`? The latter modifies the URL, so it's only necessary if you want to build cross-application links (e.g. from one admin to the other). In general, using the location `state` is a safe bet. - ## Editing A Record In A Modal `` is designed to be a page component, passed to the `edit` prop of the `` component. But you may want to let users edit a record from another page. diff --git a/docs/Inputs.md b/docs/Inputs.md index 30186752cd..41e87eb543 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -79,7 +79,7 @@ React-admin provides a set of Input components, each one designed for a specific | Tree node | `42` | [``](./TreeInput.md) | | Foreign key | `42` | [``](./ReferenceInput.md) | | Array of objects | `[{ item: 'jeans', qty: 3 }, { item: 'shirt', qty: 1 }]` | [``](./ArrayInput.md) | -| Array of Enums | `['foo', 'bar']` | [``](./TextArrayinput.md), [``](./SelectArrayInput.md), [``](./AutocompleteArrayInput.md), [``](./CheckboxGroupInput.md), [``](./DualListInput.md) | +| Array of Enums | `['foo', 'bar']` | [``](./SelectArrayInput.md), [``](./AutocompleteArrayInput.md), [``](./CheckboxGroupInput.md), [``](./DualListInput.md) | | Array of foreign keys | `[42, 43]` | [``](./ReferenceArrayInput.md) | | Translations | `{ en: 'Hello', fr: 'Bonjour' }` | [``](./TranslatableInputs.md) | | Related records | `[{ id: 42, title: 'Hello' }, { id: 43, title: 'World' }]` | [``](./ReferenceManyInput.md), [``](./ReferenceManyToManyInput.md), [``](./ReferenceNodeInput.md), [``](./ReferenceOneInput.md) | diff --git a/docs/Reference.md b/docs/Reference.md index b4c1052908..3983cda09a 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -188,7 +188,6 @@ title: "Index" * [``](./TabbedForm.md) * [``](./TabbedForm.md#versioning) * [``](./TabbedShowLayout.md) -* [``](./TextArrayInput.md) * [``](./TextField.md) * [``](./TextInput.md) * [``](./TimeInput.md) @@ -297,7 +296,6 @@ title: "Index" **- R -** * [`useRecordContext`](./useRecordContext.md) -* [`useRecordFromLocation`](./useRecordFromLocation.md) * [`useRedirect`](./useRedirect.md) * [`useReference`](./useGetOne.md#aggregating-getone-calls) * [`useRefresh`](./useRefresh.md) diff --git a/docs/SelectArrayInput.md b/docs/SelectArrayInput.md index e8451d116b..a3f0ad11bd 100644 --- a/docs/SelectArrayInput.md +++ b/docs/SelectArrayInput.md @@ -18,7 +18,6 @@ This input allows editing values that are arrays of scalar values, e.g. `[123, 4 **Tip**: React-admin includes other components allowing the edition of such values: -- [``](./TextArrayInput.md) lets you edit an array of strings - [``](./AutocompleteArrayInput.md) renders an Autocomplete - [``](./CheckboxGroupInput.md) renders a list of checkbox options - [``](./DualListInput.md) renders a list of choices that can be moved from one list to another diff --git a/docs/SimpleList.md b/docs/SimpleList.md index 98379633bd..2bf275370c 100644 --- a/docs/SimpleList.md +++ b/docs/SimpleList.md @@ -28,7 +28,7 @@ export const PostList = () => ( primaryText={record => record.title} secondaryText={record => `${record.views} views`} tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - rowClick={(id, resource, record) => record.canEdit ? "edit" : "show"} + linkType={record => record.canEdit ? "edit" : "show"} rowSx={record => ({ backgroundColor: record.nb_views >= 500 ? '#efe' : 'white' })} /> @@ -44,7 +44,7 @@ export const PostList = () => ( | `primaryText` | Optional | mixed | record representation | The primary text to display. | | `secondaryText` | Optional | mixed | | The secondary text to display. | | `tertiaryText` | Optional | mixed | | The tertiary text to display. | -| `rowClick` | Optional |mixed | `"edit"` | The action to trigger when the user clicks on a row. | +| `linkType` | Optional |mixed | `"edit"` | The target of each item click. | | `leftAvatar` | Optional | function | | A function returning an `` component to display before the primary text. | | `leftIcon` | Optional | function | | A function returning an `` component to display before the primary text. | | `rightAvatar` | Optional | function | | A function returning an `` component to display after the primary text. | @@ -80,6 +80,31 @@ This prop should be a function returning an `` component. When present, This prop should be a function returning an `` component. When present, the `` renders a `` before the `` +## `linkType` + +The `` items link to the edition page by default. You can also set the `linkType` prop to `show` directly to link to the `` page instead. + +```jsx +import { List, SimpleList } from 'react-admin'; + +export const PostList = () => ( + + record.title} + secondaryText={record => `${record.views} views`} + tertiaryText={record => new Date(record.published_at).toLocaleDateString()} + linkType="show" + /> + +); +``` + +`linkType` accepts the following values: + +* `linkType="edit"`: links to the edit page. This is the default behavior. +* `linkType="show"`: links to the show page. +* `linkType={false}`: does not create any link. + ## `primaryText` The `primaryText`, `secondaryText` and `tertiaryText` props can accept 4 types of values: @@ -165,33 +190,6 @@ This prop should be a function returning an `` component. When present, This prop should be a function returning an `` component. When present, the `` renders a `` after the ``. -## `rowClick` - -The `` items link to the edition page by default. You can also set the `rowClick` prop to `show` directly to link to the `` page instead. - -```jsx -import { List, SimpleList } from 'react-admin'; - -export const PostList = () => ( - - record.title} - secondaryText={record => `${record.views} views`} - tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - rowClick="show" - /> - -); -``` - -`rowClick` accepts the following values: - -* `rowClick="edit"`: links to the edit page. This is the default behavior. -* `rowClick="show"`: links to the show page. -* `rowClick={false}`: does not link to anything. -* `rowClick="/custom"`: links to a custom path. -* `rowClick={(id, resource, record) => path}`: path can be any of the above values - ## `rowStyle` *Deprecated - use [`rowSx`](#rowsx) instead.* @@ -256,7 +254,7 @@ export const PostList = () => { primaryText={record => record.title} secondaryText={record => `${record.views} views`} tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - rowClick={(id, resource, record) => record.canEdit ? "edit" : "show"} + linkType={record => record.canEdit ? "edit" : "show"} /> ) : ( diff --git a/docs/TextArrayInput.md b/docs/TextArrayInput.md deleted file mode 100644 index 5936ed949f..0000000000 --- a/docs/TextArrayInput.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -layout: default -title: "The TextArrayInput Component" ---- - -# `` - -`` lets you edit an array of strings, like a list of email addresses or a list of tags. It renders as an input where the current values are represented as chips. Users can add or delete new values. - - - - -## Usage - -Use `` to edit an array of strings: - -```jsx -import { Create, SimpleForm, TextArrayInput, TextInput } from 'react-admin'; - -export const EmailCreate = () => ( - - - - - - - -); -``` - -This form will allow users to input multiple email addresses in the `to` field. The resulting email will look like this: - -```jsx -{ - "to": ["jane.smith@example.com", "john.doe@acme.com"], - "subject": "Request for a quote", - "body": "Hi,\n\nI would like to know if you can provide a quote for the following items:\n\n- 100 units of product A\n- 50 units of product B\n- 25 units of product C\n\nBest regards,\n\nJulie\n", - "id": 123, - "date": "2024-11-26T11:37:22.564Z", - "from": "julie.green@example.com", -} -``` - -`` is designed for simple string arrays. For more complex use cases, consider the following alternatives: - -- [``](./SelectArrayInput.md) or [``](./AutocompleteArrayInput.md) if the possible values are limited to a predefined list. -- [``](./ReferenceArrayInput.md) if the possible values are stored in another resource. -- [``](./ArrayInput.md) if the stored value is an array of *objects* instead of an array of strings. - -## Props - -| Prop | Required | Type | Default | Description | -| ------------ | -------- | --------- | ------- | -------------------------------------------------------------------- | -| `options` | Optional | `string[]` | | Optional list of possible values for the input. If provided, the input will suggest these values as the user types. | -| `renderTags` | Optional | `(value, getTagProps) => ReactNode` | | A function to render selected value. | - -`` also accepts the [common input props](./Inputs.md#common-input-props). - -Additional props are passed down to the underlying Material UI [``](https://mui.com/material-ui/react-autocomplete/) component. - -## `options` - -You can make show a list of suggestions to the user by setting the `options` prop: - -```jsx - -``` - -## `renderTags` - -To customize the rendering of the chips, use the `renderTags` prop. This prop is a function that takes two arguments: - -- `value`: The input value (an array of strings) -- `getTagProps`: A props getter for an individual tag. - -```tsx - - value.map((option: string, index: number) => { - const { key, ...tagProps } = getTagProps({ index }); - return ( - - ); - }) - } -/> -``` \ No newline at end of file diff --git a/docs/img/TextArrayInput.mp4 b/docs/img/TextArrayInput.mp4 deleted file mode 100644 index 06fc4ed5e2..0000000000 Binary files a/docs/img/TextArrayInput.mp4 and /dev/null differ diff --git a/docs/navigation.html b/docs/navigation.html index 89e8b47f99..3f0bda7488 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -127,7 +127,6 @@
  • useEditContext
  • useEditController
  • useSaveContext
  • -
  • useRecordFromLocation
  • useRegisterMutationMiddleware
  • useUnique
@@ -213,7 +212,6 @@
  • <SelectArrayInput>
  • <SimpleFormIterator>
  • <SmartRichTextInput>
  • -
  • <TextArrayInput>
  • <TextInput>
  • <TimeInput>
  • <TranslatableInputs>
  • diff --git a/docs/useGetRecordRepresentation.md b/docs/useGetRecordRepresentation.md index 90ca4d5248..35ce7a0539 100644 --- a/docs/useGetRecordRepresentation.md +++ b/docs/useGetRecordRepresentation.md @@ -1,6 +1,6 @@ --- layout: default -title: "The useGetRecordRepresentation Hook" +title: "The useGetRecordRepresentation Component" --- # `useGetRecordRepresentation` diff --git a/docs/useRecordFromLocation.md b/docs/useRecordFromLocation.md deleted file mode 100644 index f13922c4a0..0000000000 --- a/docs/useRecordFromLocation.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -layout: default -title: "The useRecordFromLocation Hook" ---- - -# `useRecordFromLocation` - -Return a record that was passed through either [the location query or the location state](https://reactrouter.com/6.28.0/start/concepts#locations). - -You may use it to know whether the form values of the current create or edit view have been overridden from the location as supported by the [`Create`](./Create.md#prefilling-the-form) and [`Edit`](./Edit.md#prefilling-the-form) components. - -## Usage - -```tsx -// in src/posts/PostEdit.tsx -import * as React from 'react'; -import { Alert } from '@mui/material'; -import { Edit, SimpleForm, TextInput, useRecordFromLocation } from 'react-admin'; - -export const PostEdit = () => { - const recordFromLocation = useRecordFromLocation(); - return ( - - {recordFromLocation - ? ( - - The record has been modified. - - ) - : null - } - - - - - ); -} -``` - -## Options - -Here are all the options you can set on the `useRecordFromLocation` hook: - -| Prop | Required | Type | Default | Description | -| -------------- | -------- | ---------- | ---------- | -------------------------------------------------------------------------------- | -| `searchSource` | | `string` | `'source'` | The name of the location search parameter that may contains a stringified record | -| `stateSource` | | `string` | `'record'` | The name of the location state parameter that may contains a stringified record | - -## `searchSource` - -The name of the [location search](https://reactrouter.com/6.28.0/start/concepts#locations) parameter that may contains a stringified record. Defaults to `source`. - -## `stateSource` - -The name of the [location state](https://reactrouter.com/6.28.0/start/concepts#locations) parameter that may contains a stringified record. Defaults to `record`. diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 02fbf33e00..d4cd736796 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -64,7 +64,7 @@ "eventemitter3": "^5.0.1", "inflection": "^3.0.0", "jsonexport": "^3.2.0", - "lodash": "^4.17.21", + "lodash": "~4.17.5", "query-string": "^7.1.3", "react-error-boundary": "^4.0.13", "react-is": "^18.2.0" diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index b873e42638..c51cb41106 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -7,7 +7,7 @@ import { } from '@testing-library/react'; import expect from 'expect'; import React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Location, Route, Routes } from 'react-router-dom'; import { CreateContextProvider, @@ -26,11 +26,50 @@ import { useRegisterMutationMiddleware, } from '../saveContext'; import { CreateController } from './CreateController'; +import { getRecordFromLocation } from './useCreateController'; import { TestMemoryRouter } from '../../routing'; import { CanAccess } from './useCreateController.security.stories'; describe('useCreateController', () => { + describe('getRecordFromLocation', () => { + const location: Location = { + key: 'a_key', + pathname: '/foo', + search: '', + state: undefined, + hash: '', + }; + + it('should return location state record when set', () => { + expect( + getRecordFromLocation({ + ...location, + state: { record: { foo: 'bar' } }, + }) + ).toEqual({ foo: 'bar' }); + }); + + it('should return location search when set', () => { + expect( + getRecordFromLocation({ + ...location, + search: '?source={"foo":"baz","array":["1","2"]}', + }) + ).toEqual({ foo: 'baz', array: ['1', '2'] }); + }); + + it('should return location state record when both state and search are set', () => { + expect( + getRecordFromLocation({ + ...location, + state: { record: { foo: 'bar' } }, + search: '?foo=baz', + }) + ).toEqual({ foo: 'bar' }); + }); + }); + const defaultProps = { hasCreate: true, hasEdit: true, diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index be3b2f7c63..c07e6a469d 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -1,4 +1,6 @@ import { useCallback } from 'react'; +import { parse } from 'query-string'; +import { useLocation, Location } from 'react-router-dom'; import { UseMutationOptions } from '@tanstack/react-query'; import { useAuthenticated, useRequireAccess } from '../../auth'; @@ -76,9 +78,11 @@ export const useCreateController = < const { hasEdit, hasShow } = useResourceDefinition(props); const finalRedirectTo = redirectTo ?? getDefaultRedirectRoute(hasShow, hasEdit); + const location = useLocation(); const translate = useTranslate(); const notify = useNotify(); const redirect = useRedirect(); + const recordToUse = record ?? getRecordFromLocation(location) ?? undefined; const { onSuccess, onError, meta, ...otherMutationOptions } = mutationOptions; const { @@ -195,8 +199,8 @@ export const useCreateController = < saving, defaultTitle, save, - record, resource, + record: recordToUse, redirect: finalRedirectTo, registerMutationMiddleware, unregisterMutationMiddleware, @@ -235,6 +239,39 @@ export interface CreateControllerResult< saving: boolean; } +/** + * Get the initial record from the location, whether it comes from the location + * state or is serialized in the url search part. + */ +export const getRecordFromLocation = ({ state, search }: Location) => { + if (state && (state as StateWithRecord).record) { + return (state as StateWithRecord).record; + } + if (search) { + try { + const searchParams = parse(search); + if (searchParams.source) { + if (Array.isArray(searchParams.source)) { + console.error( + `Failed to parse location search parameter '${search}'. To pre-fill some fields in the Create form, pass a stringified source parameter (e.g. '?source={"title":"foo"}')` + ); + return; + } + return JSON.parse(searchParams.source); + } + } catch (e) { + console.error( + `Failed to parse location search parameter '${search}'. To pre-fill some fields in the Create form, pass a stringified source parameter (e.g. '?source={"title":"foo"}')` + ); + } + } + return null; +}; + +type StateWithRecord = { + record?: Partial; +}; + const getDefaultRedirectRoute = (hasShow, hasEdit) => { if (hasEdit) { return 'edit'; diff --git a/packages/ra-core/src/core/SourceContext.stories.tsx b/packages/ra-core/src/core/SourceContext.stories.tsx index 90068f2ee5..d5654766b8 100644 --- a/packages/ra-core/src/core/SourceContext.stories.tsx +++ b/packages/ra-core/src/core/SourceContext.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Form, useInput } from '../form'; -import { TestMemoryRouter } from '../routing'; export default { title: 'ra-core/core/SourceContext', @@ -20,23 +19,19 @@ const TextInput = props => { export const Basic = () => { return ( - -
    - - -
    +
    + + ); }; export const WithoutSourceContext = () => { const form = useForm(); return ( - - -
    - - -
    -
    + +
    + + +
    ); }; diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index a3fd7aa02b..fef541ead5 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -6,7 +6,6 @@ import * as yup from 'yup'; import assert from 'assert'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import type { To } from 'react-router'; import { CoreAdminContext } from '../core'; import { Form } from './Form'; @@ -21,7 +20,6 @@ import { NullValue, InNonDataRouter, ServerSideValidation, - MultiRoutesForm, } from './Form.stories'; import { mergeTranslations } from '../i18n'; @@ -793,94 +791,4 @@ describe('Form', () => { 'There are validation errors. Please fix them.' ); }); - - it.each([ - { - from: 'state', - url: { - pathname: '/form/general', - state: { record: { body: 'from-state' } }, - }, - expectedValue: 'from-state', - }, - { - from: 'search query', - url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To, - expectedValue: 'from-search', - }, - ])( - 'should support prefilling the from values from the location $from', - async ({ url, expectedValue }) => { - render(); - expect( - (await screen.findByLabelText('title')).value - ).toEqual(''); - expect( - (screen.getByText('Submit') as HTMLInputElement).disabled - ).toEqual(false); - fireEvent.click(screen.getByText('Settings')); - await screen.findByDisplayValue(expectedValue); - expect( - screen.getByText('Submit').disabled - ).toEqual(false); - } - ); - it.each([ - { - from: 'state', - url: { - pathname: '/form/general', - state: { record: { body: 'from-state' } }, - }, - expectedValue: 'from-state', - expectedDefaultValue: '', - }, - { - from: 'state with default values', - url: { - pathname: '/form/general', - state: { record: { body: 'from-state' } }, - }, - expectedValue: 'from-state', - defaultValues: { category: 'default category' }, - expectedDefaultValue: 'default category', - }, - { - from: 'search query', - url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To, - expectedValue: 'from-search', - expectedDefaultValue: '', - }, - { - from: 'search query with default values', - url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To, - expectedValue: 'from-search', - defaultValues: { category: 'default category' }, - expectedDefaultValue: 'default category', - }, - ])( - 'should support overriding the record values from the location $from', - async ({ url, defaultValues, expectedValue, expectedDefaultValue }) => { - render( - - ); - await screen.findByDisplayValue('lorem'); - expect( - (await screen.findByLabelText('category')) - .value - ).toEqual(expectedDefaultValue); - expect( - (screen.getByText('Submit') as HTMLInputElement).disabled - ).toEqual(false); - fireEvent.click(screen.getByText('Settings')); - await screen.findByDisplayValue(expectedValue); - expect( - screen.getByText('Submit').disabled - ).toEqual(false); - } - ); }); diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 0570b36fbd..32402820bb 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -8,24 +8,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import { - Route, - Routes, - useNavigate, - Link, - HashRouter, - useLocation, -} from 'react-router-dom'; +import { Route, Routes, useNavigate, Link, HashRouter } from 'react-router-dom'; import { CoreAdminContext } from '../core'; -import { RecordContextProvider, SaveContextProvider } from '../controller'; -import { Form, FormProps } from './Form'; +import { Form } from './Form'; import { useInput } from './useInput'; import { required, ValidationError } from './validation'; import { mergeTranslations } from '../i18n'; -import { I18nProvider, RaRecord } from '../types'; -import { TestMemoryRouter } from '../routing'; -import { useNotificationContext } from '../notification'; +import { I18nProvider } from '../types'; +import { SaveContextProvider, useNotificationContext } from '..'; export default { title: 'ra-core/form/Form', @@ -58,12 +49,10 @@ const Input = props => { }; const SubmitButton = () => { - const { dirtyFields } = useFormState(); - // useFormState().isDirty might differ from useFormState().dirtyFields (https://github.com/react-hook-form/react-hook-form/issues/4740) - const isDirty = Object.keys(dirtyFields).length > 0; + const state = useFormState(); return ( - ); @@ -414,134 +403,3 @@ export const ServerSideValidation = () => { ); }; - -export const MultiRoutesForm = ({ - url, - initialRecord, - defaultValues, -}: { - url?: any; - initialRecord?: Partial; - defaultValues?: Partial; -}) => ( - - - - - - - } - /> - - - -); - -MultiRoutesForm.args = { - url: 'unmodified', - initialRecord: 'none', -}; - -MultiRoutesForm.argTypes = { - url: { - options: [ - 'unmodified', - 'modified with location state', - 'modified with location search', - ], - mapping: { - unmodified: '/form/general', - 'modified with location state': { - pathname: '/form/general', - state: { record: { body: 'from-state' } }, - }, - 'modified with location search': `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}`, - }, - control: { type: 'select' }, - }, - defaultValues: { - options: ['none', 'provided'], - mapping: { - none: undefined, - provided: { - category: 'default category', - }, - }, - control: { type: 'select' }, - }, - initialRecord: { - options: ['none', 'provided'], - mapping: { - none: undefined, - provided: { title: 'lorem', body: 'unmodified' }, - }, - control: { type: 'select' }, - }, -}; - -const FormWithSubRoutes = (props: Partial) => { - return ( -
    - - - - ); -}; - -const TabbedForm = () => { - const location = useLocation(); - - return ( - <> -
    - - General - - - Settings - -
    - - - - - - - - - ); -}; -const Tab = ({ - children, - name, -}: { - children: React.ReactNode; - name: string; -}) => { - const location = useLocation(); - - return ( -
    - {children} -
    - ); -}; diff --git a/packages/ra-core/src/form/FormDataConsumer.spec.tsx b/packages/ra-core/src/form/FormDataConsumer.spec.tsx index 6498b37099..6bfb2fdcf1 100644 --- a/packages/ra-core/src/form/FormDataConsumer.spec.tsx +++ b/packages/ra-core/src/form/FormDataConsumer.spec.tsx @@ -12,9 +12,7 @@ import { ArrayInput, } from 'ra-ui-materialui'; import expect from 'expect'; -import { ResourceContextProvider } from '../core'; -import { Form } from '../form'; -import { TestMemoryRouter } from '../routing'; +import { Form, ResourceContextProvider } from '..'; describe('FormDataConsumerView', () => { it('does not call its children function with scopedFormData if it did not receive a source containing an index', () => { @@ -22,17 +20,15 @@ describe('FormDataConsumerView', () => { const formData = { id: 123, title: 'A title' }; render( - -
    - - {children} - -
    -
    +
    + + {children} + +
    ); expect(children).toHaveBeenCalledWith({ diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts index 0210416560..73e4ed0c12 100644 --- a/packages/ra-core/src/form/index.ts +++ b/packages/ra-core/src/form/index.ts @@ -5,7 +5,6 @@ export * from './groups'; export * from './useApplyInputDefaultValues'; export * from './useAugmentedForm'; export * from './useInput'; -export * from './useRecordFromLocation'; export * from './useSuggestions'; export * from './useWarnWhenUnsavedChanges'; export * from './validation'; diff --git a/packages/ra-core/src/form/useAugmentedForm.ts b/packages/ra-core/src/form/useAugmentedForm.ts index 6f47a36fb4..d497e9a14b 100644 --- a/packages/ra-core/src/form/useAugmentedForm.ts +++ b/packages/ra-core/src/form/useAugmentedForm.ts @@ -1,19 +1,14 @@ -import { - BaseSyntheticEvent, - useCallback, - useEffect, - useMemo, - useRef, -} from 'react'; +import { BaseSyntheticEvent, useCallback, useMemo, useRef } from 'react'; import { FieldValues, SubmitHandler, useForm, UseFormProps, } from 'react-hook-form'; -import merge from 'lodash/merge'; + import { RaRecord } from '../types'; -import { SaveHandler, useRecordContext, useSaveContext } from '../controller'; +import { SaveHandler, useSaveContext } from '../controller'; +import { useRecordContext } from '../controller'; import getFormInitialValues from './getFormInitialValues'; import { getSimpleValidationResolver, @@ -22,7 +17,6 @@ import { import { setSubmissionErrors } from './validation/setSubmissionErrors'; import { useNotifyIsFormInvalid } from './validation/useNotifyIsFormInvalid'; import { sanitizeEmptyValues as sanitizeValues } from './sanitizeEmptyValues'; -import { useRecordFromLocation } from './useRecordFromLocation'; /** * Wrapper around react-hook-form's useForm @@ -50,8 +44,8 @@ export const useAugmentedForm = ( disableInvalidFormNotification, ...rest } = props; - const saveContext = useSaveContext(); const record = useRecordContext(props); + const saveContext = useSaveContext(); const defaultValuesIncludingRecord = useMemo( () => getFormInitialValues(defaultValues, record), @@ -87,18 +81,6 @@ export const useAugmentedForm = ( // notify on invalid form useNotifyIsFormInvalid(form.control, !disableInvalidFormNotification); - const recordFromLocation = useRecordFromLocation(); - const recordFromLocationApplied = useRef(false); - const { reset } = form; - useEffect(() => { - if (recordFromLocation && !recordFromLocationApplied.current) { - reset(merge({}, defaultValuesIncludingRecord, recordFromLocation), { - keepDefaultValues: true, - }); - recordFromLocationApplied.current = true; - } - }, [defaultValuesIncludingRecord, recordFromLocation, reset]); - // submit callbacks const handleSubmit = useCallback( async (values, event) => { diff --git a/packages/ra-core/src/form/useRecordFromLocation.spec.tsx b/packages/ra-core/src/form/useRecordFromLocation.spec.tsx deleted file mode 100644 index f8c9654de9..0000000000 --- a/packages/ra-core/src/form/useRecordFromLocation.spec.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from 'react'; -import { render, screen } from '@testing-library/react'; -import { Location } from 'react-router-dom'; -import { - getRecordFromLocation, - useRecordFromLocation, -} from './useRecordFromLocation'; -import { TestMemoryRouter, UseRecordFromLocationOptions } from '..'; - -describe('useRecordFromLocation', () => { - const UseGetRecordFromLocation = (props: UseRecordFromLocationOptions) => { - const recordFromLocation = useRecordFromLocation(props); - - return
    {JSON.stringify(recordFromLocation)}
    ; - }; - it('return null if there is no location search nor state that contains a record', async () => { - render( - - - - ); - - await screen.findByText('null'); - }); - it('return the record from the location search', async () => { - const record = { test: 'value' }; - render( - - - - ); - - await screen.findByText(JSON.stringify({ test: 'value' })); - }); - it('return the record from the location state', async () => { - const record = { test: 'value' }; - render( - - - - ); - - await screen.findByText(JSON.stringify({ test: 'value' })); - }); -}); - -describe('getRecordFromLocation', () => { - const location: Location = { - key: 'a_key', - pathname: '/foo', - search: '', - state: undefined, - hash: '', - }; - - it('should return location state record when set', () => { - expect( - getRecordFromLocation({ - ...location, - state: { record: { foo: 'bar' } }, - }) - ).toEqual({ foo: 'bar' }); - }); - - it('should return location state record when set with a custom key', () => { - expect( - getRecordFromLocation( - { - ...location, - state: { myRecord: { foo: 'bar' } }, - }, - { stateSource: 'myRecord' } - ) - ).toEqual({ foo: 'bar' }); - }); - - it('should return location search when set', () => { - expect( - getRecordFromLocation({ - ...location, - search: '?source={"foo":"baz","array":["1","2"]}', - }) - ).toEqual({ foo: 'baz', array: ['1', '2'] }); - }); - - it('should return location search when set with a custom key', () => { - expect( - getRecordFromLocation( - { - ...location, - search: '?mySource={"foo":"baz","array":["1","2"]}', - }, - { - searchSource: 'mySource', - } - ) - ).toEqual({ foo: 'baz', array: ['1', '2'] }); - }); - - it('should return location state record when both state and search are set', () => { - expect( - getRecordFromLocation({ - ...location, - state: { record: { foo: 'bar' } }, - search: '?foo=baz', - }) - ).toEqual({ foo: 'bar' }); - }); -}); diff --git a/packages/ra-core/src/form/useRecordFromLocation.ts b/packages/ra-core/src/form/useRecordFromLocation.ts deleted file mode 100644 index d071596038..0000000000 --- a/packages/ra-core/src/form/useRecordFromLocation.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { parse } from 'query-string'; -import { Location, useLocation } from 'react-router-dom'; -import isEqual from 'lodash/isEqual'; -import { RaRecord } from '../types'; - -/** - * A hook that returns the record to use to override the values in a form - * @param options The hook options - * @param options.searchSource The key in the location search to use as a source for the record. Its content should be a stringified JSON object. - * @param options.stateSource The key in the location state to use as a source for the record - * @returns The record to use to override the values in a form - */ -export const useRecordFromLocation = ( - props: UseRecordFromLocationOptions = {} -) => { - const { searchSource, stateSource } = props; - const location = useLocation(); - const [recordFromLocation, setRecordFromLocation] = useState(() => - getRecordFromLocation(location, { - stateSource, - searchSource, - }) - ); - - // To avoid having the form resets when the location changes but the final record is the same - // This is needed for forms such as TabbedForm or WizardForm that may change the location for their sections - const previousRecordRef = useRef(recordFromLocation); - - useEffect(() => { - const newRecordFromLocation = getRecordFromLocation(location, { - stateSource, - searchSource, - }); - - if (!isEqual(newRecordFromLocation, previousRecordRef.current)) { - previousRecordRef.current = newRecordFromLocation; - setRecordFromLocation(newRecordFromLocation); - } - }, [location, stateSource, searchSource]); - - return recordFromLocation; -}; - -export type UseRecordFromLocationOptions = { - searchSource?: string; - stateSource?: string; -}; - -/** - * Get the initial record from the location, whether it comes from the location - * state or is serialized in the url search part. - */ -export const getRecordFromLocation = ( - { state, search }: Location, - { - searchSource = 'source', - stateSource = 'record', - }: { - searchSource?: string; - stateSource?: string; - } = {} -): Partial | null => { - if (state && state[stateSource]) { - return state[stateSource]; - } - if (search) { - try { - const searchParams = parse(search); - const source = searchParams[searchSource]; - if (source) { - if (Array.isArray(source)) { - console.error( - `Failed to parse location ${searchSource} parameter '${search}'. To pre-fill some fields in the Create form, pass a stringified ${searchSource} parameter (e.g. '?${searchSource}={"title":"foo"}')` - ); - return null; - } - return JSON.parse(source); - } - } catch (e) { - console.error( - `Failed to parse location ${searchSource} parameter '${search}'. To pre-fill some fields in the Create form, pass a stringified ${searchSource} parameter (e.g. '?${searchSource}={"title":"foo"}')` - ); - } - } - return null; -}; diff --git a/packages/ra-core/src/routing/useGetPathForRecord.ts b/packages/ra-core/src/routing/useGetPathForRecord.ts index 4a34aa559d..7d1fa19d77 100644 --- a/packages/ra-core/src/routing/useGetPathForRecord.ts +++ b/packages/ra-core/src/routing/useGetPathForRecord.ts @@ -81,11 +81,6 @@ export const useGetPathForRecord = ( useEffect(() => { if (!record) return; - if (link === false) { - setPath(false); - return; - } - // Handle the inferred link type case if (link == null) { // We must check whether the resource has an edit view because if there is no diff --git a/packages/ra-ui-materialui/src/input/TextArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/TextArrayInput.spec.tsx deleted file mode 100644 index 91942edc86..0000000000 --- a/packages/ra-ui-materialui/src/input/TextArrayInput.spec.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; - -import { Basic, HelperText, Label, Required } from './TextArrayInput.stories'; - -describe('', () => { - it('should render the values as chips', () => { - render(); - const chip1 = screen.getByText('john@example.com'); - expect(chip1.classList.contains('MuiChip-label')).toBe(true); - const chip2 = screen.getByText('albert@target.dev'); - expect(chip2.classList.contains('MuiChip-label')).toBe(true); - }); - it('should allow to remove a value', async () => { - render(); - await screen.findByText( - '["john@example.com","albert@target.dev"] (object)' - ); - const deleteButtons = screen.getAllByTestId('CancelIcon'); - fireEvent.click(deleteButtons[0]); - await screen.findByText('["albert@target.dev"] (object)'); - }); - it('should allow to remove all values one by one', async () => { - render(); - await screen.findByText( - '["john@example.com","albert@target.dev"] (object)' - ); - const deleteButtons = screen.getAllByTestId('CancelIcon'); - fireEvent.click(deleteButtons[1]); - fireEvent.click(deleteButtons[0]); - await screen.findByText('[] (object)'); - }); - it('should allow to remove all values using the reset button', async () => { - render(); - const input = screen.getByLabelText('resources.emails.fields.to'); - fireEvent.click(input); - const clearButton = screen.getByLabelText('Clear'); - fireEvent.click(clearButton); - await screen.findByText('[] (object)'); - }); - it('should allow to add a value', async () => { - render(); - const input = screen.getByLabelText('resources.emails.fields.to'); - fireEvent.change(input, { target: { value: 'bob.brown@example.com' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - await screen.findByText( - '["john@example.com","albert@target.dev","bob.brown@example.com"] (object)' - ); - }); - it('should render the helper text', () => { - render(); - screen.getByText('Email addresses of the recipients'); - }); - it('should render the custom label', () => { - render(