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

[New Feature] Add validateFileRemoval prop to FileInput for handling remove files with confirming #7003

Merged
merged 13 commits into from
Jan 18, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions docs/FileInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ title: "The FileInput Component"
| `labelSingle` | Optional | `string` | 'ra.input.file. upload_single' | Invite displayed in the drop zone if the input accepts one file |
| `labelMultiple` | Optional | `string` | 'ra.input.file. upload_several' | Invite displayed in the drop zone if the input accepts several files |
| `placeholder` | Optional | `ReactNode` | - | Invite displayed in the drop zone, overrides `labelSingle` and `labelMultiple` |
| `validateFileRemoval` | Optional | `Function` | - | Validate removing items. This is able to cancel to remove the file's list item by onRemove handler when you want to do so by throwing Error. `(file) => void \| Promise<void>` |
| `options` | Optional | `Object` | `{}` | Additional options passed to react-dropzone's `useDropzone()` hook. See [the react-dropzone source](https://github.com/react-dropzone/react-dropzone/blob/master/src/index.js) for details . |

`<FileInput>` also accepts the [common input props](./Inputs.md#common-input-props).
Expand Down Expand Up @@ -53,6 +54,60 @@ If the default Dropzone label doesn't fit with your need, you can pass a `placeh

Note that the file upload returns a [File](https://developer.mozilla.org/en/docs/Web/API/File) object. It is your responsibility to handle it depending on your API behavior. You can for instance encode it in base64, or send it as a multi-part form data. Check [this example](./DataProviders.md#handling-file-uploads) for base64 encoding data by extending the REST Client.

The `validateFileRemoval` handler can interrupt removing visual items in your page. Given if you want to remove immediately file so call your api request in `validateFileRemoval`, then the request fails and throws error, These items don't disapper in the page.

The `validateFileRemoval` can also be used to confirm the deletion of items to users. The following is an example.


```jsx
function Edit(props) {
const [removeImageConfirmEvent, setRemoveFileConfirmEvent] = useState(null);
const [isRemoveImageModalOpen, setIsRemoveImageModalOpen] = useState(false);
return (
<Edit {...props}>
<SimpleForm>
<ImageInput
source="images"
src="image"
validateFileRemoval={(file, _record) => {
const promise = new Promise((_resolve, reject) => {
setRemoveFileConfirmEvent({
fileName: `Image ID: ${file.id}`,
resolve: async (result) => {
await YourApi.deleteImages({ ids: [file.id] });
return _resolve(result);
},
reject,
});
});
setIsRemoveImageModalOpen(true);
return promise.then((result) => {
// Success Action if you want
});
}}
/>
<SomeConfirmModal
isOpen={isRemoveImageModalOpen}
title="delete image"
message={`${removeImageConfirmEvent ? removeImageConfirmEvent.fileName: ''} will be deleted`}
onSubmit={() => {
setIsRemoveImageModalOpen(false);
removeImageConfirmEvent && removeImageConfirmEvent.resolve();
}}
onCancel={() => {
setIsRemoveImageModalOpen(false);
removeImageConfirmEvent && removeImageConfirmEvent.reject();
}}
/>
</SimpleForm>
</Edit>
)
}
```

This example assumes that it can show some confirm modal has two buttons, to submit and cancel, when clicking a FileInput delete button icon. Then it interrupts to remove items in the page if "YourApi.deleteImages" fails or cancel button is clicked though when succeeding to submit they are removed.


## `sx`: CSS API

The `<FileInput>` component accepts the usual `className` prop. You can also override many styles of the inner components thanks to the `sx` property (as most Material UI components, see their [documentation about it](https://mui.com/customization/how-to-customize/#overriding-nested-component-styles)). This property accepts the following subclasses:
Expand Down
4 changes: 2 additions & 2 deletions docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Then you can display a text input to edit the author first name as follows:

**Tip**: For compatibility reasons, input components also accept the `defaultValue` prop - which is simply copied as the `initialValue` prop.

## Recipes
## Recipes

### Transforming Input Value to/from Record

Expand Down Expand Up @@ -124,7 +124,7 @@ const dateParser = v => {

Edition forms often contain linked inputs, e.g. country and city (the choices of the latter depending on the value of the former).

React-admin relies on [react-final-form](https://final-form.org/docs/react-final-form/getting-started) for form handling. You can grab the current form values using react-final-form [useFormState](https://final-form.org/docs/react-final-form/api/useFormState) hook.
React-admin relies on [react-final-form](https://final-form.org/docs/react-final-form/getting-started) for form handling. You can grab the current form values using react-final-form [useFormState](https://final-form.org/docs/react-final-form/api/useFormState) hook.

```jsx
import * as React from 'react';
Expand Down
170 changes: 168 additions & 2 deletions packages/ra-ui-materialui/src/input/FileInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as React from 'react';
import { render, fireEvent } from '@testing-library/react';
import {
render,
fireEvent,
waitForElementToBeRemoved,
waitFor,
} from '@testing-library/react';
import { Form } from 'react-final-form';

import { FileField, ImageField } from '../field';
import { FileInput } from './FileInput';

Expand Down Expand Up @@ -247,6 +251,168 @@ describe('<FileInput />', () => {
});
});

describe('should call validateFileRemoval on removal to allow developers to conditionally prevent the removal', () => {
it('normal function', async () => {
const onSubmit = jest.fn();

const { getByLabelText, getByTitle } = render(
<Form
initialValues={{
image: {
src: 'test.png',
title: 'cats',
},
}}
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<FileInput
{...defaultProps}
validateFileRemoval={file => {
throw Error('Cancel Removal Action');
}}
>
<FileField source="src" title="title" />
</FileInput>
<button type="submit" aria-label="Save" />
</form>
)}
/>
);

const fileDom = getByTitle('cats');
expect(fileDom).not.toBeNull();
fireEvent.click(getByLabelText('ra.action.delete'));
await waitFor(() => {
expect(fileDom).not.toBeNull();
});
fireEvent.click(getByLabelText('Save'));

expect(onSubmit.mock.calls[0][0]).toEqual({
image: {
src: 'test.png',
title: 'cats',
},
});
});
it('promise function', async () => {
const onSubmit = jest.fn();

const { getByLabelText, getByTitle } = render(
<Form
initialValues={{
image: {
src: 'test.png',
title: 'cats',
},
}}
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<FileInput
{...defaultProps}
validateFileRemoval={async file => {
throw Error('Cancel Removal Action');
}}
>
<FileField source="src" title="title" />
</FileInput>
<button type="submit" aria-label="Save" />
</form>
)}
/>
);
const fileDom = getByTitle('cats');
expect(fileDom).not.toBeNull();
fireEvent.click(getByLabelText('ra.action.delete'));
await waitFor(() => {
expect(fileDom).not.toBeNull();
});
fireEvent.click(getByLabelText('Save'));
expect(onSubmit.mock.calls[0][0]).toEqual({
image: {
src: 'test.png',
title: 'cats',
},
});
});
});

describe('should continue to remove file field validateFileRemoval without Promise rejected.', () => {
it('normal function', async () => {
const onSubmit = jest.fn();

const { getByLabelText, getByTitle } = render(
<Form
initialValues={{
image: {
src: 'test.png',
title: 'cats',
},
}}
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<FileInput
{...defaultProps}
validateFileRemoval={file => true}
>
<FileField source="src" title="title" />
</FileInput>
<button type="submit" aria-label="Save" />
</form>
)}
/>
);

const fileDom = getByTitle('cats');
expect(fileDom).not.toBeNull();
fireEvent.click(getByLabelText('ra.action.delete'));
await waitForElementToBeRemoved(fileDom);
fireEvent.click(getByLabelText('Save'));

expect(onSubmit.mock.calls[0][0]).toEqual({
image: null,
});
});
it('promise function', async () => {
const onSubmit = jest.fn();

const { getByLabelText, getByTitle } = render(
<Form
initialValues={{
image: {
src: 'test.png',
title: 'cats',
},
}}
onSubmit={onSubmit}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<FileInput
{...defaultProps}
validateFileRemoval={async file => true}
>
<FileField source="src" title="title" />
</FileInput>
<button type="submit" aria-label="Save" />
</form>
)}
/>
);

const fileDom = getByTitle('cats');
expect(fileDom).not.toBeNull();
fireEvent.click(getByLabelText('ra.action.delete'));
await waitForElementToBeRemoved(fileDom);
fireEvent.click(getByLabelText('Save'));

expect(onSubmit.mock.calls[0][0]).toEqual({
image: null,
});
});
});

it('should display correct custom label', () => {
const test = (expectedLabel, expectedLabelText = expectedLabel) => {
const { getByText } = render(
Expand Down
12 changes: 11 additions & 1 deletion packages/ra-ui-materialui/src/input/FileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const FileInput = (
maxSize,
minSize,
multiple = false,
validateFileRemoval,
options: {
inputProps: inputPropsOptions,
...options
Expand Down Expand Up @@ -111,7 +112,14 @@ export const FileInput = (
}
};

const onRemove = file => () => {
const onRemove = file => async () => {
if (validateFileRemoval) {
try {
await validateFileRemoval(file);
} catch (e) {
return;
}
}
if (multiple) {
const filteredFiles = files.filter(
stateFile => !shallowEqual(stateFile, file)
Expand Down Expand Up @@ -216,6 +224,7 @@ FileInput.propTypes = {
maxSize: PropTypes.number,
minSize: PropTypes.number,
multiple: PropTypes.bool,
validateFileRemoval: PropTypes.func,
options: PropTypes.object,
resource: PropTypes.string,
source: PropTypes.string,
Expand Down Expand Up @@ -250,6 +259,7 @@ export interface FileInputProps
DropzoneOptions,
'accept' | 'multiple' | 'maxSize' | 'minSize'
> {
validateFileRemoval?(file): boolean | Promise<boolean>;
children?: ReactNode;
labelMultiple?: string;
labelSingle?: string;
Expand Down