Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix null value support in inputs #8262

Merged
merged 32 commits into from Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ff41d83
Fix null value support in inputs
slax57 Oct 13, 2022
a5d2720
remove useless format prop
slax57 Oct 13, 2022
39657d3
Add more tests
fzaninotto Oct 13, 2022
95e03aa
Handle undefined values, too
fzaninotto Oct 13, 2022
43df168
No need for format as it is already in useInput
fzaninotto Oct 13, 2022
83a5b76
Add more tests to SelectIInput
fzaninotto Oct 13, 2022
aafc64e
AutocompleteInput
slax57 Oct 14, 2022
c9e3879
extract FormInspector
slax57 Oct 14, 2022
41bd57f
DateInput
slax57 Oct 14, 2022
7b58efc
DateTimeInput
slax57 Oct 14, 2022
b8b5c9a
TimeInput
slax57 Oct 14, 2022
0928ac3
FileInput
slax57 Oct 14, 2022
9014786
ImageInput
slax57 Oct 14, 2022
ce222d4
SelectInput
slax57 Oct 14, 2022
2da082e
NullableBooleanInput
slax57 Oct 14, 2022
0f3fba7
RadioButtonGroupInput
slax57 Oct 14, 2022
ebe1575
RichTextInput
slax57 Oct 14, 2022
626cf59
move default parse to useInput
slax57 Oct 14, 2022
a66f05c
update sanitizeEmptyValues to handle null
slax57 Oct 14, 2022
ace29f3
[no ci] update doc
slax57 Oct 14, 2022
6aa5c03
add test about default parse in useInput
slax57 Oct 14, 2022
4af3f8f
rollback useless change
slax57 Oct 14, 2022
a1b89bc
remove unused parse in textinput
slax57 Oct 14, 2022
a418c55
Update Parse and format doc
fzaninotto Oct 14, 2022
748cd23
Fix input doc tranform tutorial
fzaninotto Oct 14, 2022
9d163d3
rewrite sanitizeEmptyValues doc
fzaninotto Oct 14, 2022
d1ff5f3
Remove useless defaultValue in SelectInput and AutocompleteInput
fzaninotto Oct 14, 2022
c1010cf
fix Inputs.md regarding sanitizeEmptyValues
slax57 Oct 17, 2022
e3b5d68
update sanitizeEmptyValues story and test
slax57 Oct 17, 2022
8c82a02
update sanitizeEmptyValues docs
slax57 Oct 17, 2022
413cd6b
document uncontrolled warning in custom inputs
slax57 Oct 17, 2022
ea943fe
fix linter warning
slax57 Oct 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 21 additions & 6 deletions docs/Form.md
Expand Up @@ -136,17 +136,21 @@ export const PostCreate = () => {

## `sanitizeEmptyValues`

In HTML, the value of empty form inputs is the empty string (`''`) by default. React-hook-form doesn't sanitize these values. This leads to unexpected `create` and `update` payloads like:
In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.) automatically transform these empty values into `null`.

But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like:

```jsx
{
id: 123,
title: '',
author: '',
}
id: 1234,
title: 'Lorem Ipsum',
is_published: '',
body: '',
// etc.
}
```

To avoid that, set the `sanitizeEmptyValues` prop to `true`. This will remove empty strings from the form state on submit, unless the record actually had a value for that field.
If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition.

```jsx
const PostCreate = () => (
Expand All @@ -158,6 +162,17 @@ const PostCreate = () => (
);
```

For the previous example, the data sent to the `dataProvider` will be:

```jsx
{
id: 1234,
title: 'Lorem Ipsum',
}
```

**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field.

If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `<Edit>` or `<Create>` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs.

## `validate`
Expand Down
59 changes: 31 additions & 28 deletions docs/Inputs.md
Expand Up @@ -44,11 +44,11 @@ All input components accept the following props:
| `className` | Optional | `string` | - | Class name (usually generated by JSS) to customize the look and feel of the field element itself |
| `defaultValue` | Optional | `any` | - | Default value of the input. |
| `disabled` | Optional | `boolean` | - | If true, the input is disabled. |
| `format` | Optional | `Function` | - | Callback taking the value from the form state, and returning the input value. |
| `format` | Optional | `Function` | `value => value == null ? '' : value` | Callback taking the value from the form state, and returning the input value. |
| `fullWidth` | Optional | `boolean` | `false` | If `true`, the input will expand to fill the form width |
| `helperText` | Optional | `string` | - | Text to be displayed under the input |
| `label` | Optional | `string` | - | Input label. In i18n apps, the label is passed to the `translate` function. Defaults to the humanized `source` when omitted. Set `label={false}` to hide the label. |
| `parse` | Optional | `Function` | - | Callback taking the input value, and returning the value you want stored in the form state. |
| `parse` | Optional | `Function` | `value => value === '' ? null : value` | Callback taking the input value, and returning the value you want stored in the form state. |
| `sx` | Optional | `SxProps` | - | MUI shortcut for defining custom styles |
| `validate` | Optional | `Function` &#124; `array` | - | Validation rules for the current property. See the [Validation Documentation](./Validation.md#per-input-validation-built-in-field-validators) for details. |

Expand Down Expand Up @@ -142,6 +142,12 @@ form state value --> format --> form input value (string)

`format` often comes in pair with [`parse`](#parse) to transform the input value before storing it in the form state. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details.

**Tip:** By default, react-admin inputs have to following `format` function, which turns any `null` or `undefined` value into an empty string. This is to avoid warnings about controlled/uncontrolled input components:

```js
const defaultFormat = (value: any) => value == null ? '' : value;
```

## `fullWidth`

If `true`, the input will expand to fill the form width.
Expand Down Expand Up @@ -215,6 +221,12 @@ form input value (string) ---> parse ---> form state value

`parse` often comes in pair with [`format`](#format) to transform the form value before passing it to the input. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details.

**Tip:** By default, react-admin inputs have to following `parse` function, which transforms any empty string into `null`:

```js
const defaultParse = (value: string) => value === '' ? null : value;
```

## `source`

Specifies the field of the record that the input should edit.
Expand Down Expand Up @@ -331,30 +343,13 @@ Mnemonic for the two functions:
- `parse()`: input -> record
- `format()`: record -> input

A common usage for this feature is to strip empty strings from the record before saving it to the API. As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. Leveraging `parse` allows you to transform the empty string to `null` before saving the record.

```jsx
import { TextInput } from 'react-admin';

const TextInputWithNullEmptyValue = props => (
<TextInput
{...props}
parse={v => v === '' ? null : v}
/>
);

export default TextInputWithNullEmptyValue;
```

**Tip**: If you need to do that for every input, use [the `sanitizeEmptyValues` prop of the `<Form>` component](./Form.md#sanitizeemptyvalues) instead.

Let's look at another usage example. Say the user would like to input values of 0-100 to a percentage field but your API (hence record) expects 0-1.0. You can use simple `parse()` and `format()` functions to archive the transform:
Let's look at a simple example. Say the user would like to input values of 0-100 to a percentage field but your API (hence record) expects 0-1.0. You can use simple `parse()` and `format()` functions to archive the transform:

```jsx
<NumberInput source="percent" format={v => v * 100} parse={v => parseFloat(v) / 100} label="Formatted number" />
```

`<DateInput>` stores and returns a string. If you would like to store a JavaScript Date object in your record instead:
Another classical use-case is with handling dates. `<DateInput>` stores and returns a string. If you would like to store a JavaScript Date object in your record instead, you can do something like this:

```jsx
const dateFormatRegex = /^\d{4}-\d{2}-\d{2}$/;
Expand Down Expand Up @@ -393,6 +388,10 @@ const dateParser = value => {
<DateInput source="isodate" format={dateFormatter} parse={dateParser} defaultValue={new Date()} />
```

**Tip:** A common usage for this feature is to deal with empty values. Indeed HTML form inputs always return strings, even for numbers and booleans, however most backends expect a value like `null`. This is why, by default, all react-admin inputs will store the value `null` when the HTML input value is `''`.

**Tip**: If you need to do this globally, including for custom input components that do not use [the `useInput` hook](#the-useinput-hook), have a look at [the `sanitizeEmptyValues` prop of the `<Form>` component](./Form.md#sanitizeemptyvalues).

## Linking Two Inputs

Edition forms often contain linked inputs, e.g. country and city (the choices of the latter depending on the value of the former).
Expand Down Expand Up @@ -603,8 +602,8 @@ For instance, let's write a component to edit the latitude and longitude of the
import { useController } from 'react-hook-form';

const LatLngInput = () => {
const input1 = useController({ name: 'lat' });
const input2 = useController({ name: 'lng' });
const input1 = useController({ name: 'lat', defaultValue: '' });
const input2 = useController({ name: 'lng', defaultValue: '' });

return (
<span>
Expand Down Expand Up @@ -635,14 +634,16 @@ const ItemEdit = () => (
</span>
```

**Tip**: Notice that we have added `defaultValue: ''` as one of the `useController` params. This is a good practice to avoid getting console warnings about controlled/uncontrolled components, that may arise if the value of `record.lat` or `record.lng` is `undefined` or `null`.

**Tip**: React-hook-form's `useController` component supports dot notation in the `name` prop, to allow binding to nested values:

```jsx
import { useController } from 'react-hook-form';

const LatLngInput = () => {
const input1 = useController({ name: 'position.lat' });
const input2 = useController({ name: 'position.lng' });
const input1 = useController({ name: 'position.lat', defaultValue: '' });
const input2 = useController({ name: 'position.lng', defaultValue: '' });

return (
<span>
Expand All @@ -665,8 +666,8 @@ import { useController } from 'react-hook-form';
import { Labeled } from 'react-admin';

const LatLngInput = () => {
const input1 = useController({ name: 'lat' });
const input2 = useController({ name: 'lng' });
const input1 = useController({ name: 'lat', defaultValue: '' });
const input2 = useController({ name: 'lng', defaultValue: '' });

return (
<Labeled label="position">
Expand Down Expand Up @@ -705,7 +706,7 @@ const BoundedTextField = ({ name, label }) => {
field,
fieldState: { isTouched, invalid, error },
formState: { isSubmitted }
} = useController(name);
} = useController(name, defaultValue: '');
return (
<TextField
{...field}
Expand All @@ -726,6 +727,8 @@ const LatLngInput = () => (

**Tip**: MUI's `<TextField>` component already includes a label, so you don't need to use `<Labeled>` in this case.

**Tip**: Notice that we have added `defaultValue: ''` as one of the `useController` params. This is a good practice to avoid getting console warnings about controlled/uncontrolled components, that may arise if the value of `record.lat` or `record.lng` is `undefined` or `null`.

`useController()` returns three values: `field`, `fieldState`, and `formState`. To learn more about these props, please refer to the [useController](https://react-hook-form.com/api/usecontroller) hook documentation.

Instead of HTML `input` elements or MUI components, you can use react-admin input components, like `<NumberInput>` for instance. React-admin components already use `useController()`, and already include a label, so you don't need either `useController()` or `<Labeled>` when using them:
Expand Down
8 changes: 6 additions & 2 deletions docs/SimpleForm.md
Expand Up @@ -144,7 +144,9 @@ export const PostCreate = () => {

## `sanitizeEmptyValues`

As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. This means that the data sent to the form handler will contain empty strings:
In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.) automatically transform these empty values into `null`.

But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like:

```jsx
{
Expand All @@ -156,7 +158,7 @@ As a reminder, HTML form inputs always return strings, even for numbers and bool
}
```

React-hook-form doesn't sanitize these values. If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition.
If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition.

```jsx
const PostCreate = () => (
Expand All @@ -177,6 +179,8 @@ For the previous example, the data sent to the `dataProvider` will be:
}
```

**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field.

If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `<Edit>` or `<Create>` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs.

## `sx`: CSS API
Expand Down
8 changes: 6 additions & 2 deletions docs/TabbedForm.md
Expand Up @@ -192,7 +192,9 @@ export const PostCreate = () => (

## `sanitizeEmptyValues`

As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. This means that the data sent to the form handler will contain empty strings:
In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.) automatically transform these empty values into `null`.

But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like:

```jsx
{
Expand All @@ -204,7 +206,7 @@ As a reminder, HTML form inputs always return strings, even for numbers and bool
}
```

React-hook-form doesn't sanitize these values. If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition.
If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition.

```jsx
const PostCreate = () => (
Expand All @@ -225,6 +227,8 @@ For the previous example, the data sent to the `dataProvider` will be:
}
```

**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like `<TextInput>`, `<NumberInput>`, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field.

If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `<Edit>` or `<Create>` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs.

## `syncWithLocation`
Expand Down
14 changes: 13 additions & 1 deletion packages/ra-core/src/form/Form.spec.tsx
Expand Up @@ -573,10 +573,22 @@ describe('Form', () => {
fireEvent.change(screen.getByLabelText('field4'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field11'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field12'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field14'), {
target: { value: 'hello' },
});
fireEvent.change(screen.getByLabelText('field14'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() =>
expect(screen.getByTestId('result')?.textContent).toEqual(
'{\n "id": 1,\n "field1": null,\n "field6": null\n}'
'{\n "id": 1,\n "field1": null,\n "field6": null,\n "field11": null,\n "field16": null\n}'
)
);
});
Expand Down
54 changes: 52 additions & 2 deletions packages/ra-core/src/form/Form.stories.tsx
@@ -1,5 +1,9 @@
import * as React from 'react';
import { useFormState } from 'react-hook-form';
import {
useController,
UseControllerProps,
useFormState,
} from 'react-hook-form';

import { CoreAdminContext } from '../core';
import { Form } from './Form';
Expand Down Expand Up @@ -64,13 +68,54 @@ export const Basic = () => {
);
};

const CustomInput = (props: UseControllerProps) => {
const { field, fieldState } = useController(props);
return (
<div
style={{
display: 'flex',
gap: '1em',
margin: '1em',
alignItems: 'center',
}}
>
<label htmlFor={field.name}>{field.name}</label>
<input
aria-label={field.name}
id={field.name}
type="text"
aria-invalid={fieldState.invalid}
{...field}
/>
<p>{fieldState.error?.message}</p>
</div>
);
};

export const SanitizeEmptyValues = () => {
const [submittedData, setSubmittedData] = React.useState<any>();
const field11 = { name: 'field11' };
const field12 = {
name: 'field12',
defaultValue: 'bar',
};
const field13 = {
name: 'field13',
defaultValue: '',
};
const field14 = { name: 'field14' };
const field16 = { name: 'field16' };
return (
<CoreAdminContext>
<Form
onSubmit={data => setSubmittedData(data)}
record={{ id: 1, field1: 'bar', field6: null }}
record={{
id: 1,
field1: 'bar',
field6: null,
field11: 'bar',
field16: null,
}}
sanitizeEmptyValues
>
<Input source="field1" />
Expand All @@ -79,6 +124,11 @@ export const SanitizeEmptyValues = () => {
<Input source="field4" />
<Input source="field5" parse={v => v || undefined} />
<Input source="field6" />
<CustomInput {...field11} />
<CustomInput {...field12} />
<CustomInput {...field13} />
<CustomInput {...field14} />
<CustomInput {...field16} />

<SubmitButton />
</Form>
Expand Down
6 changes: 0 additions & 6 deletions packages/ra-core/src/form/getFormInitialValues.ts
Expand Up @@ -10,12 +10,6 @@ export default function getFormInitialValues(
getValues(defaultValues, record),
record
);
// replace null values by empty string to avoid controlled/ uncontrolled input warning
Object.keys(finalInitialValues).forEach(key => {
if (finalInitialValues[key] === null) {
finalInitialValues[key] = '';
}
});
return finalInitialValues;
}

Expand Down