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 27 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
4 changes: 3 additions & 1 deletion docs/Form.md
Expand Up @@ -136,7 +136,9 @@ 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
{
Expand Down
41 changes: 20 additions & 21 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 React-admin to completely remove all empty values (that did not change) from the record upon submission, 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
6 changes: 4 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 Down
6 changes: 4 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 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
2 changes: 1 addition & 1 deletion packages/ra-core/src/form/sanitizeEmptyValues.ts
Expand Up @@ -7,7 +7,7 @@
export const sanitizeEmptyValues = (values: any, record: any = {}): any => {
const sanitizedValues = {};
Object.keys(values).forEach(key => {
if (values[key] === '') {
if (values[key] == null || values[key] === '') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you updated this function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For react-admin inputs (such as TextInput), when the field is undefined from the dataProvider:

  • if you touch the field (e.g. change it to 'foo' then back to '')
  • if your field has a defaultValue but you clear it
    => In both cases we will send "field": null, although the field was absent from what the dataProvider returned.

This is because sanitizeEmptyValues only triggers on '' but not on null, however our components now return null.

IMO in this case sanitizeEmptyValues should still remove the values from what's been sent (hence my change of the implementation is needed).

I'll add a story to play with this to the branch in any case so that you can see for yourself 😉

if (record.hasOwnProperty(key)) {
// user has emptied a field, make the value null
sanitizedValues[key] = null;
Expand Down
10 changes: 8 additions & 2 deletions packages/ra-core/src/form/useApplyInputDefaultValues.ts
Expand Up @@ -13,13 +13,19 @@ export const useApplyInputDefaultValues = (
) => {
const { defaultValue, source } = props;
const record = useRecordContext(props);
const { getValues, resetField } = useFormContext();
const {
getValues,
resetField,
getFieldState,
formState,
} = useFormContext();
const recordValue = get(record, source);
const formValue = get(getValues(), source);
const { isDirty } = getFieldState(source, formState);

useEffect(() => {
if (defaultValue == null) return;
if (formValue == null && recordValue == null) {
if (formValue == null && recordValue == null && !isDirty) {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
// special case for ArrayInput: since we use get(record, source),
// if source is like foo.23.bar, this effect will run.
// but we only want to set the default value for the subfield bar
Expand Down