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

Add automated empty values sanitization #8188

Merged
merged 17 commits into from
Sep 30, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/Form.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Here are all the props you can set on the `<Form>` component:
* [`id`](#id)
* [`noValidate`](#novalidate)
* [`onSubmit`](#onsubmit)
* [`sanitizeEmptyValues`](#sanitizeemptyvalues)
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
* [`validate`](#validate)
* [`warnWhenUnsavedChanges`](#warnwhenunsavedchanges)

Expand Down Expand Up @@ -133,6 +134,32 @@ 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:

```jsx
{
id: 123,
title: '',
author: '',
}
```

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.

```jsx
const PostCreate = () => (
<Create>
<Form sanitizeEmptyValues>
...
</Form>
</Create>
);
```

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`

The value of the form `validate` prop must be a function taking the record as input, and returning an object with error messages indexed by field. For instance:
Expand Down
4 changes: 3 additions & 1 deletion docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,15 @@ import { TextInput } from 'react-admin';
const TextInputWithNullEmptyValue = props => (
<TextInput
{...props}
parse={ v => v === '' ? null : v }
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:

```jsx
Expand Down
75 changes: 38 additions & 37 deletions docs/SimpleForm.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Here are all the props you can set on the `<SimpleForm>` component:
* [`id`](#id)
* [`noValidate`](#novalidate)
* [`onSubmit`](#onsubmit)
* [`sanitizeEmptyValues`](#sanitizeemptyvalues)
* [`sx`](#sx-css-api)
* [`toolbar`](#toolbar)
* [`validate`](#validate)
Expand Down Expand Up @@ -141,6 +142,43 @@ 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:

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

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.

```jsx
const PostCreate = () => (
<Create>
<SimpleForm sanitizeEmptyValues>
...
</SimpleForm>
</Create>
);
```

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

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

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

Pass an `sx` prop to customize the style of the main component and the underlying elements.
Expand Down Expand Up @@ -334,43 +372,6 @@ export const TagEdit = () => (
);
```

## Cleaning Up Empty Strings

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:

```js
{
title: '',
average_note: '',
body: '',
// etc.
}
```

If you prefer to have `null` values, or to omit the key for empty values, use `transform` prop of the parent component ([`<Edit>`](./Edit.md#transform) or [`<Create>`](./Create.md#transform)) to sanitize the form data before passing it to the `dataProvider`:

```jsx
export const UserEdit = (props) => {
const transform = (data) => {
const sanitizedData = {};
for (const key in data) {
if (typeof data[key] === "string" && data[key].trim().length === 0) continue;
sanitizedData[key] = data[key];
}
return sanitizedData;
};
return (
<Edit {...props} transform={transform}>
<SimpleForm>
...
</SimpleForm>
</Edit>
);
}
```

As an alternative, you can clean up empty values at the input level, using [the `parse` prop](./Inputs.md#transforming-input-value-tofrom-record).

## Using Fields As Children

The basic usage of `<SimpleForm>` is to pass [Input components](./Inputs.md) as children. For non-editable fields, you can pass `disabled` inputs, or even [Field components](./Fields.md). But since `<Field>` components have no label by default, you'll have to wrap your inputs in a `<Labeled>` component in that case:
Expand Down
72 changes: 37 additions & 35 deletions docs/TabbedForm.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,43 @@ export const PostCreate = () => (

**Tip:** If you want to customize the _content_ of the tabs instead, for example to limit the width of the form, you should rather add an `sx` prop to the [`<FormTab>` component](#formtab).

## `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:

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

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.

```jsx
const PostCreate = () => (
<Create>
<TabbedForm sanitizeEmptyValues>
...
</TabbedForm>
</Create>
);
```

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

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

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`

When the user clicks on a tab header, react-admin changes the URL to enable the back button.
Expand Down Expand Up @@ -479,41 +516,6 @@ const ProductEdit = () => (

**Tip**: React-admin renders each tab *twice*: once to get the tab header, and once to get the tab content. If you use a custom component instead of a `<FormTab>`, make sure that it accepts an `intent` prop, and renders differently when the value of that prop is 'header' or 'content'.

## Cleaning Up Empty Strings

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:

```js
{
title: '',
average_note: '',
body: '',
// etc.
}
```

If you prefer to have `null` values, or to omit the key for empty values, use `transform` prop of the parent component ([`<Edit>`](./Edit.md#transform) or [`<Create>`](./Create.md#transform)) to sanitize the form data before passing it to the `dataProvider`:

```jsx
export const UserEdit = (props) => {
const transform = (data) => {
const sanitizedData = {};
for (const key in data) {
if (typeof data[key] === "string" && data[key].trim().length === 0) continue;
sanitizedData[key] = data[key];
}
return sanitizedData;
};
return (
<Edit {...props} transform={transform}>
<TabbedForm>
...
</TabbedForm>
</Edit>
);
}
```

## Using Fields As FormTab Children

The basic usage of `<TabbedForm>` is to pass [Input components](./Inputs.md) as children of `<FormTab>`. For non-editable fields, you can pass `disabled` inputs, or even [Field components](./Fields.md). But since `<Field>` components have no label by default, you'll have to wrap your inputs in a `<Labeled>` component in that case:
Expand Down
28 changes: 26 additions & 2 deletions packages/ra-core/src/form/Form.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { fireEvent, screen, render, waitFor } from '@testing-library/react';
import { useFormState, useFormContext } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import assert from 'assert';

import { CoreAdminContext } from '../core';

import { Form } from './Form';
import { useNotificationContext } from '../notification';
import { useInput } from './useInput';
import { required } from './validate';
import assert from 'assert';
import { SanitizeEmptyValues } from './Form.stories';

describe('Form', () => {
const Input = props => {
Expand Down Expand Up @@ -557,6 +557,30 @@ describe('Form', () => {
});
});

describe('sanitizeEmtpyValues', () => {
it('should remove empty values from the record', async () => {
render(<SanitizeEmptyValues />);
fireEvent.change(screen.getByLabelText('field1'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field2'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field4'), {
target: { value: 'hello' },
});
fireEvent.change(screen.getByLabelText('field4'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() =>
expect(screen.getByTestId('result')?.textContent).toEqual(
'{\n "id": 1,\n "field1": null\n}'
)
);
});
});

it('should accept react-hook-form resolvers', async () => {
const onSubmit = jest.fn();
const schema = yup
Expand Down
88 changes: 88 additions & 0 deletions packages/ra-core/src/form/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as React from 'react';
import { useFormState } from 'react-hook-form';

import { CoreAdminContext } from '../core';
import { Form } from './Form';
import { useInput } from './useInput';

export default {
title: 'ra-core/form/Form',
};

const Input = props => {
const { field, fieldState } = useInput(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>
);
};

const SubmitButton = () => {
const state = useFormState();

return (
<button type="submit" disabled={!state.isDirty}>
Submit
</button>
);
};

export const Basic = () => {
const [submittedData, setSubmittedData] = React.useState<any>();
return (
<CoreAdminContext>
<Form
onSubmit={data => setSubmittedData(data)}
record={{ id: 1, field1: 'bar' }}
>
<Input source="field1" />
<Input source="field2" defaultValue="bar" />
<Input source="field3" defaultValue="" />
<Input source="field4" />
<Input source="field5" parse={v => v || undefined} />
<SubmitButton />
</Form>
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
</CoreAdminContext>
);
};

export const SanitizeEmptyValues = () => {
const [submittedData, setSubmittedData] = React.useState<any>();
return (
<CoreAdminContext>
<Form
onSubmit={data => setSubmittedData(data)}
record={{ id: 1, field1: 'bar' }}
sanitizeEmptyValues
>
<Input source="field1" />
<Input source="field2" defaultValue="bar" />
<Input source="field3" defaultValue="" />
<Input source="field4" />
<Input source="field5" parse={v => v || undefined} />

<SubmitButton />
</Form>
<pre data-testid="result">
{JSON.stringify(submittedData, null, 2)}
</pre>
</CoreAdminContext>
);
};
1 change: 1 addition & 0 deletions packages/ra-core/src/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ export interface FormOwnProps {
resource?: string;
onSubmit?: SubmitHandler<FieldValues>;
warnWhenUnsavedChanges?: boolean;
sanitizeEmptyValues?: boolean;
}
Loading