(react) add file uploader

Add mono and multi file uploader according to sketches.
This commit is contained in:
Nathan Vasse
2023-06-22 17:16:26 +02:00
committed by NathanVss
parent af3caed43f
commit 80e8dc45eb
18 changed files with 1655 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
add file uploader

View File

@@ -0,0 +1,161 @@
import React, {
forwardRef,
PropsWithChildren,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import classNames from "classnames";
import { useCunningham } from ":/components/Provider";
import { replaceInputFilters } from ":/components/Forms/FileUploader/utils";
import { Loader } from ":/components/Loader";
import {
FileUploaderProps,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
interface DropZoneProps extends FileUploaderProps, PropsWithChildren {
files: File[];
}
export const DropZone = forwardRef<FileUploaderRefType, DropZoneProps>(
(
{
multiple,
name,
state,
icon,
animateIcon,
successIcon,
uploadingIcon,
text,
bigText,
files,
onFilesChange,
children,
...props
}: DropZoneProps,
ref
) => {
const [dragActive, setDragActive] = useState(false);
const container = useRef<HTMLLabelElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useCunningham();
useImperativeHandle(ref, () => ({
get input() {
return inputRef.current;
},
reset() {
onFilesChange?.({ target: { value: [] } });
},
}));
useEffect(() => {
if (!inputRef.current) {
return;
}
replaceInputFilters(inputRef.current, files);
}, [files]);
useEffect(() => {
onFilesChange?.({ target: { value: files ?? [] } });
}, [files]);
const renderIcon = () => {
if (state === "success") {
return successIcon ?? <span className="material-icons">done</span>;
}
if (state === "uploading") {
return React.cloneElement(uploadingIcon ?? <Loader size="small" />, {
"aria-label": t("components.forms.file_uploader.uploading"),
});
}
return icon ?? <span className="material-icons">upload</span>;
};
const renderCaption = () => {
if (state === "uploading") {
return t("components.forms.file_uploader.uploading");
}
if (bigText) {
return bigText;
}
return (
<>
{t("components.forms.file_uploader.caption")}
<span>{t("components.forms.file_uploader.browse_files")}</span>
</>
);
};
return (
<label
className={classNames(
"c__file-uploader",
"c__file-uploader--" + state,
{
"c__file-uploader--active": dragActive,
"c__file-uploader--animate-icon": animateIcon,
}
)}
onDragEnter={() => {
setDragActive(true);
}}
onDragLeave={(e) => {
/**
* This condition is important because onDragLeave is called when the cursor goes over
* a child of the current node, which is not intuitive. So here we need to make sure that
* the relatedTarget is not a child of the current node.
*/
if (!container.current!.contains(e.relatedTarget as Node)) {
setDragActive(false);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
// To prevent a new tab to open.
e.preventDefault();
const newFiles = Array.from(e.dataTransfer.files);
if (inputRef.current) {
inputRef.current.files = e.dataTransfer.files;
onFilesChange?.({ target: { value: [...newFiles] } });
}
setDragActive(false);
}}
ref={container}
>
<div className="c__file-uploader__inner">
<div className="c__file-uploader__inner__icon">{renderIcon()}</div>
{children ?? (
<>
<div className="c__file-uploader__inner__caption">
{renderCaption()}
</div>
{text && (
<div className="c__file-uploader__inner__text">{text}</div>
)}
</>
)}
<input
type="file"
name={name}
ref={inputRef}
onChange={(e) => {
if (e.target.files) {
onFilesChange?.({ target: { value: [...e.target.files] } });
} else {
onFilesChange?.({ target: { value: [] } });
}
}}
multiple={multiple}
{...props}
/>
</div>
</label>
);
}
);

View File

@@ -0,0 +1,94 @@
import React, { forwardRef, useEffect, useMemo, useState } from "react";
import { useCunningham } from ":/components/Provider";
import { Button } from ":/components/Button";
import {
FileUploaderProps,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { DropZone } from ":/components/Forms/FileUploader/DropZone";
export const FileUploaderMono = forwardRef<
FileUploaderRefType,
FileUploaderProps
>(({ fakeDefaultFiles, ...props }, ref) => {
const { t } = useCunningham();
const [file, setFile] = useState<File | undefined>(
fakeDefaultFiles && fakeDefaultFiles.length > 0
? fakeDefaultFiles[0]
: undefined
);
// This is made to prevent useEffects inside DropZone that depends on `files` to trigger on each render,
// doing this preserves the reference of the array.
const files = useMemo(() => (file ? [file] : []), [file]);
const [hoverDelete, setHoverDelete] = useState(false);
const [icon, animateIcon] = useMemo(() => {
if (hoverDelete) {
return [
props.deleteIcon ?? <span className="material-icons">delete</span>,
true,
];
}
if (file) {
return [
props.fileSelectedIcon ?? (
<span className="material-icons">download</span>
),
false,
];
}
return [props.icon, true];
}, [file, hoverDelete]);
const deleteFile = (e: React.MouseEvent<HTMLElement>) => {
setFile(undefined);
// This is to prevent opening the browse file window.
e.preventDefault();
setHoverDelete(false);
};
useEffect(() => {
props.onFilesChange?.({ target: { value: file ? [file] : [] } });
}, [file]);
return (
<>
<DropZone
{...props}
files={files}
onFilesChange={(e) => setFile(e.target.value[0])}
icon={icon}
animateIcon={animateIcon}
ref={ref}
>
{file && (
<>
<div className="c__file-uploader__inner__filename">{file.name}</div>
{/* We cannot use Button component for "Delete file" because it is wrapper inside a label, when clicking */}
{/* on the label if a button is wrapped inside it triggers the button click too. But in our situation if a file has */}
{/* already been choosen, clicking on the label should trigger the browse file window and not JUST remove the file. ( We */}
{/* must click specifically on the button to remove the file ) */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="c__file-uploader__inner__actions"
onClick={deleteFile}
onMouseEnter={() => {
setHoverDelete(true);
}}
onMouseLeave={() => setHoverDelete(false)}
>
{t("components.forms.file_uploader.delete_file")}
</div>
</>
)}
</DropZone>
{file && (
<>
{/* This one is for a11y purposes. */}
<Button onClick={deleteFile} className="c__offscreen">
{t("components.forms.file_uploader.delete_file")}
</Button>
</>
)}
</>
);
});

View File

@@ -0,0 +1,62 @@
import React, { forwardRef, useEffect, useState } from "react";
import { useCunningham } from ":/components/Provider";
import { formatBytes } from ":/components/Forms/FileUploader/utils";
import { Button } from ":/components/Button";
import {
FileUploaderProps,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { DropZone } from ":/components/Forms/FileUploader/DropZone";
export const FileUploaderMulti = forwardRef<
FileUploaderRefType,
FileUploaderProps
>(({ fullWidth, fakeDefaultFiles, ...props }, ref) => {
const { t } = useCunningham();
const [files, setFiles] = useState<File[]>(fakeDefaultFiles || []);
useEffect(() => {
props.onFilesChange?.({ target: { value: files } });
}, [files]);
return (
<>
<DropZone
{...props}
files={files}
onFilesChange={(e) => setFiles(e.target.value)}
animateIcon={true}
ref={ref}
/>
{files.length > 0 && (
<div className="c__file-uploader__files">
{files.map((file) => (
<div
className="c__file-uploader__file"
key={"" + file.name + file.size + file.type + file.lastModified}
>
<div className="c__file-uploader__file__name">{file.name}</div>
<div className="c__file-uploader__file__specs">
<span>{formatBytes(file.size)}</span>
<Button
color="tertiary"
size="small"
aria-label={t(
"components.forms.file_uploader.delete_file_name",
{
name: file.name,
}
)}
onClick={() => {
setFiles(files.filter((f) => f !== file));
}}
icon={<span className="material-icons">close</span>}
/>
</div>
</div>
))}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,123 @@
import { ArgTypes, Canvas, Meta, Source, Story } from '@storybook/blocks';
import * as Stories from './index.stories';
import { FileUploader } from './index';
<Meta of={Stories}/>
# FileUploader
Cunningham provides a file uploader component that you can use in your forms.
<Canvas>
<Story id="components-forms-fileuploader--mono"/>
</Canvas>
<Source
language='ts'
dark
format={false}
code={`import { FileUploader } from "@openfun/cunningham-react";`}
/>
## Multi
The file uploader comes with a multi version to handle multiple files.
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--multi-wih-files"/>
</Canvas>
## States
You can use the following props to change the state of the FileUploader component by using the `state` props.
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-with-file-success"/>
</Canvas>
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-error"/>
</Canvas>
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-uploading"/>
</Canvas>
## Texts
You can customize displayed texts by using `bigText` and `text` props.
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-with-text"/>
</Canvas>
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-with-big-text"/>
</Canvas>
## Icons
You can customize the icons used by the FileUploader using `icon`, `successIcon`, `deleteIcon`, `fileSelectedIcon` and `uploadingIcon`.
> You can also disable the icon animation on hover by using `animateIcon` props.
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-custom-icons"/>
</Canvas>
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-custom-icons-success"/>
</Canvas>
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--mono-custom-icons-uploading"/>
</Canvas>
## Controlled / Non Controlled
This component cannot be entirely controlled like others due to browser restrictions. (For security reasons, browsers do not allow you to set an arbitrary to a file input.)
What you can do is to use the `onChange` callback to be aware of any changes in case you need to do something with the file(s).
You can also reset the input by using the `reset` method available via the ref.
It works the same for multiple or mono version.
<Canvas sourceState="shown">
<Story id="components-forms-fileuploader--multi-value"/>
</Canvas>
## Props
You can use all the props of the native html `<input>` element props plus the following.
<ArgTypes of={FileUploader} />
## Design tokens
Here are the custom design tokens defined by the button.
| Token | Description |
|---------------|----------------------------- |
| background-color | Background color of the dropzone |
| border-color | Border color of the dropzone |
| border-radius | Border radius of the dropzone |
| border-width | Border width of the dropzone |
| border-style | Border style of the dropzone |
| background-color--active | Background color of the dropzone when active or on hover |
| color | Color of the dropzone text |
| padding | Padding of the dropzone |
| accent-color | Accent color of the dropzone |
| text-color | Color of the small text |
| text-size | Font size of the small text |
| file-text-size | (Multi only) Font size of the file text |
| file-text-color | (Multi only) Font color of the file text |
| file-text-weight | (Multi only) Font weight of the file text |
| file-border-color | (Multi only) Border color of file |
| file-border-width | (Multi only) Border width of file |
| file-border-radius | (Multi only) Border radius of file |
| file-background-color | (Multi only) Background color of file |
| file-specs-size | (Multi only) Font size of file specs |
| file-specs-color | (Multi only) Font color of file specs |
See also [Field](?path=/story/components-forms-field-doc--page)

View File

@@ -0,0 +1,162 @@
.c__file-uploader {
display: flex;
align-items: center;
justify-content: center;
border-width: var(--c--components--forms-fileuploader--border-width);
border-style: var(--c--components--forms-fileuploader--border-style);
border-color: var(--c--components--forms-fileuploader--border-color);
border-radius: var(--c--components--forms-fileuploader--border-radius);
color: var(--c--components--forms-fileuploader--color);
font-weight: var(--c--theme--font--weights--light);
padding: var(--c--components--forms-fileuploader--padding);
position: relative;
cursor: pointer;
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
background-color: var(--c--components--forms-fileuploader--background-color);
&--success {
border-color: var(--c--theme--colors--success-600);
.c__file-uploader__inner {
&__icon {
color: var(--c--theme--colors--success-600);
}
}
}
&--error {
border-color: var(--c--theme--colors--danger-600);
.c__file-uploader__inner {
&__icon {
color: var(--c--theme--colors--danger-600);
}
&__text {
color: var(--c--theme--colors--danger-600);
}
}
}
&__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
input {
display: none;
}
&__caption {
span {
color: var(--c--components--forms-fileuploader--accent-color);
}
}
&__text {
font-size: var(--c--components--forms-fileuploader--text-size);
color: var(--c--components--forms-fileuploader--text-color);
}
&__filename {
color: var(--c--components--forms-fileuploader--accent-color);
text-align: center;
word-break: break-word;
}
&__actions {
text-align: center;
font-size: var(--c--components--forms-fileuploader--text-size);
color: var(--c--components--forms-fileuploader--text-color);
&:hover {
color: var(--c--theme--colors--danger-600);
}
}
}
&__files {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__file {
background-color: var(--c--components--forms-fileuploader--file-background-color);
border-radius: var(--c--components--forms-fileuploader--file-border-radius);
border-style: solid;
border-color: var(--c--components--forms-fileuploader--file-border-color);
border-width: var(--c--components--forms-fileuploader--file-border-width);
font-size: var(--c--components--forms-fileuploader--file-text-size);
font-weight: var(--c--components--forms-fileuploader--file-text-weight);
color: var(--c--components--forms-fileuploader--file-text-color);
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
&__name {
padding: 0.5rem;
min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__specs {
display: flex;
align-items: center;
font-size: var(--c--components--forms-fileuploader--file-specs-size);
color: var(--c--components--forms-fileuploader--file-specs-color);
flex-shrink: 0;
.material-icons {
font-size: 1rem;
}
}
}
&--active,
&:hover {
border-color: var(--c--components--forms-fileuploader--accent-color);
background-color: var(--c--components--forms-fileuploader--background-color--active);
&.c__file-uploader--animate-icon {
.c__file-uploader__inner {
&__icon {
animation-name: bounce-5;
animation-timing-function: var(--c--theme--transitions--ease-out);
animation-duration: 2s;
animation-iteration-count: infinite;
@keyframes bounce-5 {
0% {
transform: scale(1, 1) translateY(0);
}
10% {
transform: scale(1.1, 0.9) translateY(0);
}
30% {
transform: scale(0.9, 1.1) translateY(-10px);
}
50% {
transform: scale(1, 1) translateY(0);
}
57% {
transform: scale(1, 1) translateY(-2px);
}
64% {
transform: scale(1, 1) translateY(0);
}
100% {
transform: scale(1, 1) translateY(0);
}
}
}
}
}
}
}

View File

@@ -0,0 +1,579 @@
import { act, render, screen } from "@testing-library/react";
import React, { useRef, useState } from "react";
import userEvent from "@testing-library/user-event";
import { CunninghamProvider } from ":/components/Provider";
import {
FileUploader,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { Button } from ":/components/Button";
/**
* There are limitations in this test:
* jestdom does not support replacing input.files with another FileList, which is what we do in the component when
* we delete a file from the list for example.
* So what we do is we mock the utils module and replace the replaceInputFilters function with a no-op. The consequence
* of this is that the real files ( as value ) of the input are not replaced.
*/
vi.mock("./utils", async () => {
const actual: any = await vi.importActual("./utils");
return { ...actual, replaceInputFilters: () => {} };
});
describe("<FileUploader />", () => {
describe("Mono", () => {
it("should select a file and display its name", async () => {
render(
<CunninghamProvider>
<FileUploader />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const file = new File(["hello"], "hello.png", { type: "image/png" });
await act(async () => {
await user.upload(input, file);
});
await screen.findByText("hello.png");
expect(
screen.queryByLabelText(/Drag and drop or /)
).not.toBeInTheDocument();
});
it("should select a file and delete it", async () => {
render(
<CunninghamProvider>
<FileUploader />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const file = new File(["hello"], "hello.png", { type: "image/png" });
await act(async () => {
await user.upload(input, file);
});
await screen.findByText("hello.png");
expect(
screen.queryByLabelText(/Drag and drop or /)
).not.toBeInTheDocument();
const deleteButton = screen.getByRole("button", {
name: "Delete file",
});
await act(async () => {
await user.click(deleteButton);
});
expect(screen.queryByText("hello.png")).not.toBeInTheDocument();
screen.getByLabelText(/Drag and drop or /);
});
it("should submit selected file in form", async () => {
/**
* At first I wanted to test the file existence in the formData with onSubmit callback,
* but it seems that jsdom does not support
* FormData: it always gives an empty file when using .get("file") even if input.files is fulfilled with
* the wanted file. So I just test that the file is well set in input.files.
*/
render(
<CunninghamProvider>
<FileUploader name="file" />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const file = new File(["hello"], "hello.png", { type: "image/png" });
expect(input.files!.length).toEqual(0);
await act(async () => {
await user.upload(input, file);
});
await screen.findByText("hello.png");
expect(input.files!.length).toEqual(1);
expect(input.files![0]).toStrictEqual(file);
});
it("should be in state=uploading", async () => {
render(
<CunninghamProvider>
<FileUploader state="uploading" />
</CunninghamProvider>
);
expect(
document.querySelector(".c__file-uploader--uploading")
).toBeInTheDocument();
screen.getByText("Uploading...");
screen.getByRole("status", { name: "Uploading..." });
});
it("should be in state=success", async () => {
render(
<CunninghamProvider>
<FileUploader state="success" />
</CunninghamProvider>
);
expect(
document.querySelector(".c__file-uploader--success")
).toBeInTheDocument();
expect(document.querySelector(".material-icons")?.textContent).toContain(
"done"
);
});
it("should be in state=error with custom bigText or filename", async () => {
render(
<CunninghamProvider>
<FileUploader state="error" bigText="Error file is too big" />
</CunninghamProvider>
);
// Big text is shown is file is not selected.
expect(
document.querySelector(".c__file-uploader--error")
).toBeInTheDocument();
screen.getByText("Error file is too big");
// Upload a file.
const user = userEvent.setup();
const input: HTMLInputElement = screen.getByLabelText(
/Error file is too big/
);
const file = new File(["hello"], "hello.png", { type: "image/png" });
await act(async () => {
await user.upload(input, file);
});
// The filename is shown in place of the big text.
expect(
screen.queryByText("Error file is too big")
).not.toBeInTheDocument();
await screen.findByText("hello.png");
});
it("should display text", async () => {
render(
<CunninghamProvider>
<FileUploader text="JPG, PNG or GIF - Max file size 2MB" />
</CunninghamProvider>
);
screen.getByText("JPG, PNG or GIF - Max file size 2MB");
});
it("should display custom icon, fileSelectedIcon, deleteIcon", async () => {
render(
<CunninghamProvider>
<FileUploader
icon={<span>custom_icon</span>}
fileSelectedIcon={<span>file_selected_custom_icon</span>}
deleteIcon={<span>delete_custom_icon</span>}
/>
</CunninghamProvider>
);
screen.getByText("custom_icon");
expect(
screen.queryByText("file_selected_custom_icon")
).not.toBeInTheDocument();
expect(screen.queryByText("delete_custom_icon")).not.toBeInTheDocument();
// Upload a file.
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const file = new File(["hello"], "hello.png", { type: "image/png" });
await act(async () => {
await user.upload(input, file);
});
// Now the selected custom icon must be shown.
expect(screen.queryByText("custom_icon")).not.toBeInTheDocument();
screen.getByText("file_selected_custom_icon");
expect(screen.queryByText("delete_custom_icon")).not.toBeInTheDocument();
// Hover delete file to show the custom delete icon.
const spanDelete = document.querySelector(
".c__file-uploader__inner__actions"
)!;
await act(async () => user.hover(spanDelete));
expect(screen.queryByText("custom_icon")).not.toBeInTheDocument();
expect(
screen.queryByText("file_selected_custom_icon")
).not.toBeInTheDocument();
screen.getByText("delete_custom_icon");
});
it("should display custom successIcon", async () => {
render(
<CunninghamProvider>
<FileUploader
successIcon={<span>custom_success_icon</span>}
state="success"
/>
</CunninghamProvider>
);
screen.getByText("custom_success_icon");
});
it("should display custom uploadingIcon", async () => {
render(
<CunninghamProvider>
<FileUploader
uploadingIcon={<span>custom_uploading_icon</span>}
state="uploading"
/>
</CunninghamProvider>
);
screen.getByText("custom_uploading_icon");
});
it("can be reset in a controlled way and triggers onChange", async () => {
const Wrapper = () => {
const ref = useRef<FileUploaderRefType>(null);
const [value, setValue] = useState<File[]>([]);
return (
<CunninghamProvider>
<div>
<div>Value: {value.map((file) => file.name).join(", ")}|</div>
<FileUploader
onFilesChange={(e) => setValue(e.target.value)}
ref={ref}
/>
<Button onClick={() => ref.current?.reset()}>Reset</Button>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const file = new File(["hello"], "hello.png", { type: "image/png" });
// No file is selected.
screen.getByText("Value: |");
// Upload a file.
await act(async () => {
await user.upload(input, file);
});
screen.getByText("Value: hello.png|");
// Reset the file.
const resetButton = screen.getByRole("button", { name: "Reset" });
await act(async () => {
await user.click(resetButton);
});
screen.getByText("Value: |");
});
});
describe("Multi", () => {
const expectFiles = (expectedFiles: { name: string; specs: string }[]) => {
const actualElements = document.querySelectorAll(
".c__file-uploader__file"
);
const actual = Array.from(actualElements).map((element) => {
return {
name: element.querySelector(".c__file-uploader__file__name")!
.textContent,
specs: element.querySelector(".c__file-uploader__file__specs span")!
.textContent,
};
});
expect(actual).toEqual(expectedFiles);
};
it("should select files and list them", async () => {
render(
<CunninghamProvider>
<FileUploader multiple={true} />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
await act(async () => {
await user.upload(input, files);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
});
it("should select files delete them one by one", async () => {
render(
<CunninghamProvider>
<FileUploader multiple={true} />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
await act(async () => {
await user.upload(input, files);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
await act(async () => {
await user.click(
screen.getByRole("button", {
name: "Delete file hello.png",
})
);
});
expectFiles([{ name: "foo.png", specs: "3 Bytes" }]);
await act(async () => {
await user.click(
screen.getByRole("button", {
name: "Delete file foo.png",
})
);
});
expectFiles([]);
});
it("should submit selected files in form", async () => {
/**
* At first I wanted to test the file existence in the formData with onSubmit callback,
* but it seems that jsdom does not support
* FormData: it always gives an empty file when using .get("file") even if input.files is fulfilled with
* the wanted file. So I just test that the file is well set in input.files.
*/
render(
<CunninghamProvider>
<FileUploader name="files" multiple={true} />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
expectFiles([]);
expect(input.files!.length).toEqual(0);
await act(async () => {
await user.upload(input, files);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
expect(input.files!.length).toEqual(2);
expect(input.files![0]).toStrictEqual(files[0]);
expect(input.files![1]).toStrictEqual(files[1]);
});
it("should remove previous selected files when choosing new files", async () => {
render(
<CunninghamProvider>
<FileUploader name="files" multiple={true} />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const files1 = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
const files2 = [
new File(["bye"], "bye.png", { type: "image/png" }),
new File(["bar"], "bar.png", { type: "image/png" }),
];
expectFiles([]);
await act(async () => {
await user.upload(input, files1);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
await act(async () => {
await user.upload(input, files2);
});
expectFiles([
{ name: "bye.png", specs: "3 Bytes" },
{ name: "bar.png", specs: "3 Bytes" },
]);
});
it("should be in state=uploading", async () => {
render(
<CunninghamProvider>
<FileUploader state="uploading" multiple={true} />
</CunninghamProvider>
);
const user = userEvent.setup();
const input: HTMLInputElement = document.querySelector("input")!;
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
await act(async () => {
await user.upload(input, files);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
expect(
document.querySelector(".c__file-uploader--uploading")
).toBeInTheDocument();
screen.getByText("Uploading...");
screen.getByRole("status", { name: "Uploading..." });
});
it("should be in state=success", async () => {
render(
<CunninghamProvider>
<FileUploader state="success" multiple={true} />
</CunninghamProvider>
);
expect(
document.querySelector(".c__file-uploader--success")
).toBeInTheDocument();
expect(document.querySelector(".material-icons")?.textContent).toContain(
"done"
);
});
it("should be in state=error", async () => {
render(
<CunninghamProvider>
<FileUploader
state="error"
bigText="Error file is too big"
multiple={true}
/>
</CunninghamProvider>
);
// Big text is shown is file is not selected.
expect(
document.querySelector(".c__file-uploader--error")
).toBeInTheDocument();
screen.getByText("Error file is too big");
const user = userEvent.setup();
const input: HTMLInputElement = document.querySelector("input")!;
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
await act(async () => {
await user.upload(input, files);
});
expectFiles([
{ name: "hello.png", specs: "5 Bytes" },
{ name: "foo.png", specs: "3 Bytes" },
]);
// The error is still displayed if files are selected.
expect(
document.querySelector(".c__file-uploader--error")
).toBeInTheDocument();
screen.getByText("Error file is too big");
});
it("should display text", async () => {
render(
<CunninghamProvider>
<FileUploader
text="JPG, PNG or GIF - Max file size 2MB"
multiple={true}
/>
</CunninghamProvider>
);
screen.getByText("JPG, PNG or GIF - Max file size 2MB");
});
it("can be reset in a controlled way and triggers onChange", async () => {
const Wrapper = () => {
const ref = useRef<FileUploaderRefType>(null);
const [value, setValue] = useState<File[]>([]);
return (
<CunninghamProvider>
<div>
<div>Value: {value.map((file) => file.name).join(", ")}|</div>
<FileUploader
onFilesChange={(e) => setValue(e.target.value)}
ref={ref}
multiple={true}
/>
<Button onClick={() => ref.current?.reset()}>Reset</Button>
</div>
</CunninghamProvider>
);
};
render(<Wrapper />);
const user = userEvent.setup();
const input: HTMLInputElement =
screen.getByLabelText(/Drag and drop or /);
const files = [
new File(["hello"], "hello.png", { type: "image/png" }),
new File(["foo"], "foo.png", { type: "image/png" }),
];
// No file is selected.
screen.getByText("Value: |");
// Upload a file.
await act(async () => {
await user.upload(input, files);
});
screen.getByText("Value: hello.png, foo.png|");
// Reset the file.
const resetButton = screen.getByRole("button", { name: "Reset" });
await act(async () => {
await user.click(resetButton);
});
screen.getByText("Value: |");
});
});
});

View File

@@ -0,0 +1,342 @@
import { Meta, StoryFn } from "@storybook/react";
import React, { useEffect, useRef, useState } from "react";
import { faker } from "@faker-js/faker";
import {
FileUploader,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { CunninghamProvider } from ":/components/Provider";
import { Button } from ":/components/Button";
const Template: StoryFn<typeof FileUploader> = (args) => (
<CunninghamProvider>
<FileUploader {...args} />
</CunninghamProvider>
);
const FAKE_FILES = [
new File(["42"], faker.lorem.sentence(5) + "pdf"),
new File(["42"], faker.lorem.sentence(1) + "pdf"),
new File(["42"], faker.lorem.sentence(2) + "pdf"),
new File(["42"], faker.lorem.sentence(3) + "pdf"),
];
const meta: Meta<typeof FileUploader> = {
title: "Components/Forms/FileUploader",
component: FileUploader,
render: Template,
};
export default meta;
export const Mono = {};
export const MonoWithText = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
},
};
export const MonoWithBigText = {
args: {
bigText: "Hi there",
},
};
export const MonoWithFile = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
};
export const MonoWithFileSuccess = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
state: "success",
},
};
export const MonoError = {
args: {
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "error",
},
};
export const MonoErrorWithFile = {
args: {
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "error",
fakeDefaultFiles: FAKE_FILES,
},
};
export const MonoUploading = {
args: {
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "uploading",
},
};
export const MonoCustomIcons = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
icon: <span className="material-icons">add_box</span>,
fileSelectedIcon: <span className="material-icons">attach_file</span>,
deleteIcon: <span className="material-icons">backspace</span>,
},
};
export const MonoCustomIconsSuccess = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
state: "success",
successIcon: <span className="material-icons">verified</span>,
},
};
export const MonoCustomIconsUploading = {
args: {
text: "JPG, PNG or GIF - Max file size 2MB",
state: "uploading",
uploadingIcon: <span className="material-icons">update</span>,
},
};
export const MonoStatesShowcase = {
render: () => {
const stepToProps: Record<string, any> = {
default: {
text: "JPG, PNG or GIF - Max file size 2MB",
},
fileSelected: {
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
uploading: {
state: "uploading",
text: "JPG, PNG or GIF - Max file size 2MB",
},
success: {
state: "success",
text: "JPG, PNG or GIF - Max file size 2MB",
},
error: {
state: "error",
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
},
};
const steps = Object.keys(stepToProps);
const [step, setStep] = React.useState(steps[0]);
useEffect(() => {
const timeout = setTimeout(() => {
setStep(steps[(steps.indexOf(step) + 1) % steps.length]);
}, 2000);
return () => clearTimeout(timeout);
}, [step]);
// The key is here to re-render the component when the state changes only when we want to display
// the fake default files. In all other step we want it to be persistant in order to see transitions ( like the
// border color ).
return (
<Template
key={step === "fileSelected" ? step : "const"}
{...stepToProps[step]}
/>
);
},
};
export const MonoValue = {
render: () => {
const ref = useRef<FileUploaderRefType>(null);
const [value, setValue] = useState<File[]>([]);
return (
<CunninghamProvider>
<div>
<div>Value: {value.map((file) => file.name).join(", ")}</div>
<FileUploader
onFilesChange={(e) => setValue(e.target.value)}
ref={ref}
/>
<Button onClick={() => ref.current?.reset()}>Reset</Button>
</div>
</CunninghamProvider>
);
},
};
export const Multi = {
args: {
multiple: true,
},
};
export const MultiWihText = {
args: {
multiple: true,
text: "JPG, PNG or GIF - Max file size 2MB",
},
};
export const MultiWihFiles = {
args: {
multiple: true,
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
};
export const MultiWithFileSuccess = {
args: {
multiple: true,
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
state: "success",
},
};
export const MultiError = {
args: {
multiple: true,
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "error",
},
};
export const MultiErrorWithFile = {
args: {
multiple: true,
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "error",
fakeDefaultFiles: FAKE_FILES,
},
};
export const MultiUploadingWithFiles = {
args: {
multiple: true,
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
state: "uploading",
fakeDefaultFiles: FAKE_FILES,
},
};
export const MultiStatesShowcase = {
render: () => {
const stepToProps: Record<string, any> = {
default: {
text: "JPG, PNG or GIF - Max file size 2MB",
},
fileSelected: {
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
uploading: {
state: "uploading",
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
success: {
state: "success",
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
error: {
state: "error",
bigText: "Error file is too big",
text: "JPG, PNG or GIF - Max file size 2MB",
fakeDefaultFiles: FAKE_FILES,
},
};
const steps = Object.keys(stepToProps);
const [step, setStep] = React.useState(steps[0]);
useEffect(() => {
const timeout = setTimeout(() => {
setStep(steps[(steps.indexOf(step) + 1) % steps.length]);
}, 2000);
return () => clearTimeout(timeout);
}, [step]);
// The key is here to re-render the component when the state changes only when we want to display
// the fake default files. In all other step we want it to be persistant in order to see transitions ( like the
// border color ).
return (
<Template
key={step === "fileSelected" ? step : "const"}
multiple={true}
{...stepToProps[step]}
/>
);
},
};
export const MultiValue = {
render: () => {
const ref = useRef<FileUploaderRefType>(null);
const [value, setValue] = useState<File[]>([]);
return (
<CunninghamProvider>
<div>
<div>Value: {value.map((file) => file.name).join(", ")}</div>
<FileUploader
onFilesChange={(e) => setValue(e.target.value)}
ref={ref}
multiple={true}
/>
<Button onClick={() => ref.current?.reset()}>Reset</Button>
</div>
</CunninghamProvider>
);
},
};
export const Form = {
render: () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = new FormData(e.target as any);
// eslint-disable-next-line no-console
console.log("SUBMIT", data.getAll("files"));
// eslint-disable-next-line no-console
console.log("SUBMIT", data.getAll("file"));
};
return (
<CunninghamProvider>
<form onSubmit={handleSubmit}>
<div>
<FileUploader
name="files"
text="JPG, PNG or GIF - Max file size 2MB"
accept="image/png, image/jpeg, image/gif"
multiple={true}
/>
</div>
<div className="mt-s">
<FileUploader
name="file"
text="JPG, PNG or GIF - Max file size 2MB"
accept="image/png, image/jpeg, image/gif"
/>
</div>
<div className="mt-s">
<Button>Submit</Button>
</div>
</form>
</CunninghamProvider>
);
},
};

View File

@@ -0,0 +1,41 @@
import React, { forwardRef, InputHTMLAttributes, ReactElement } from "react";
import { Field, FieldProps, FieldState } from ":/components/Forms/Field";
import { InputRefType } from ":/components/Forms/Input";
import { FileUploaderMulti } from ":/components/Forms/FileUploader/FileUploaderMulti";
import { FileUploaderMono } from ":/components/Forms/FileUploader/FileUploaderMono";
export interface FileUploaderProps
extends Omit<FieldProps, "state">,
InputHTMLAttributes<HTMLInputElement> {
state?: FieldState | "uploading" | undefined;
multiple?: boolean;
icon?: ReactElement;
successIcon?: ReactElement;
deleteIcon?: ReactElement;
fileSelectedIcon?: ReactElement;
uploadingIcon?: ReactElement;
animateIcon?: boolean;
name?: string;
bigText?: string;
onFilesChange?: (event: { target: { value: File[] } }) => void;
// This is only here for storybook. It cannot be used in real conditions.
fakeDefaultFiles?: File[];
}
export interface FileUploaderRefType extends InputRefType {
reset: () => void;
}
export const FileUploader = forwardRef<FileUploaderRefType, FileUploaderProps>(
({ fullWidth, ...props }, ref) => {
return (
<Field fullWidth={fullWidth}>
{props.multiple ? (
<FileUploaderMulti {...props} ref={ref} />
) : (
<FileUploaderMono {...props} ref={ref} />
)}
</Field>
);
}
);

View File

@@ -0,0 +1,24 @@
import { DefaultTokens } from "@openfun/cunningham-tokens";
export const tokens = (defaults: DefaultTokens) => ({
"background-color": "white",
"border-color": defaults.theme.colors["greyscale-300"],
"border-radius": "0.5rem",
"border-width": "1px",
"border-style": "dotted",
"background-color--active": defaults.theme.colors["primary-100"],
color: defaults.theme.colors["greyscale-900"],
padding: "1rem",
"accent-color": defaults.theme.colors["primary-600"],
"text-color": defaults.theme.colors["greyscale-600"],
"text-size": "0.6875rem",
"file-text-size": "0.8125rem",
"file-text-color": defaults.theme.colors["greyscale-900"],
"file-text-weight": defaults.theme.font.weights.light,
"file-border-color": defaults.theme.colors["greyscale-300"],
"file-border-width": "1px",
"file-border-radius": "0.5rem",
"file-background-color": "white",
"file-specs-size": "0.6875rem",
"file-specs-color": defaults.theme.colors["greyscale-600"],
});

View File

@@ -0,0 +1,24 @@
/**
* FROM https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
* @param bytes
* @param decimals
*/
export function formatBytes(bytes: number, decimals: number = 2) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const exponent = Math.floor(Math.log(bytes) / Math.log(k));
return (
parseFloat((bytes / k ** exponent).toFixed(decimals)) +
" " +
units[exponent]
);
}
export const replaceInputFilters = (input: HTMLInputElement, files: File[]) => {
const dataTransfer = new DataTransfer();
files.forEach((f) => {
dataTransfer.items.add(f);
});
input.files = dataTransfer.files;
};

View File

@@ -142,6 +142,26 @@
--c--components--forms-input--color: #303C4B;
--c--components--forms-input--label-color--focus: #0556BF;
--c--components--forms-input--background-color: white;
--c--components--forms-fileuploader--background-color: white;
--c--components--forms-fileuploader--border-color: #E7E8EA;
--c--components--forms-fileuploader--border-radius: 0.5rem;
--c--components--forms-fileuploader--border-width: 1px;
--c--components--forms-fileuploader--border-style: dotted;
--c--components--forms-fileuploader--background-color--active: #EBF2FC;
--c--components--forms-fileuploader--color: #0C1A2B;
--c--components--forms-fileuploader--padding: 1rem;
--c--components--forms-fileuploader--accent-color: #0556BF;
--c--components--forms-fileuploader--text-color: #79818A;
--c--components--forms-fileuploader--text-size: 0.6875rem;
--c--components--forms-fileuploader--file-text-size: 0.8125rem;
--c--components--forms-fileuploader--file-text-color: #0C1A2B;
--c--components--forms-fileuploader--file-text-weight: 300;
--c--components--forms-fileuploader--file-border-color: #E7E8EA;
--c--components--forms-fileuploader--file-border-width: 1px;
--c--components--forms-fileuploader--file-border-radius: 0.5rem;
--c--components--forms-fileuploader--file-background-color: white;
--c--components--forms-fileuploader--file-specs-size: 0.6875rem;
--c--components--forms-fileuploader--file-specs-color: #79818A;
--c--components--forms-field--width: 292px;
--c--components--forms-field--font-size: 0.6875rem;
--c--components--forms-field--color: #79818A;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,7 @@
@import './components/DataGrid';
@import './components/Forms/Checkbox';
@import './components/Forms/Field';
@import './components/Forms/FileUploader';
@import './components/Forms/Radio';
@import './components/Forms/Input';
@import './components/Forms/LabelledBox';

View File

@@ -8,6 +8,7 @@ export * from "./components/DataGrid/SimpleDataGrid";
export * from "./components/Forms/Checkbox";
export * from "./components/DataGrid/DataList";
export * from "./components/Forms/Field";
export * from "./components/Forms/FileUploader";
export * from "./components/Forms/Input";
export * from "./components/Forms/Radio";
export * from "./components/Forms/Select";

View File

@@ -24,6 +24,13 @@
"clear_button_aria_label": "Clear selection",
"clear_all_button_aria_label": "Clear all selections"
},
"file_uploader": {
"delete_file_name": "Delete file {name}",
"delete_file": "Delete file",
"uploading": "Uploading...",
"caption": "Drag and drop or ",
"browse_files": "Browse files"
},
"date_picker": {
"toggle_button_aria_label_open": "Open calendar",
"toggle_button_aria_label_close": "Close calendar",

View File

@@ -22,6 +22,13 @@
"clear_button_aria_label": "Effacer la sélection",
"clear_all_button_aria_label": "Effacer toutes les sélections"
},
"file_uploader": {
"delete_file_name": "Supprimer le fichier {name}",
"delete_file": "Supprimer le fichier",
"uploading": "Upload en cours ...",
"caption": "Glisser-déposer ou ",
"browse_files": "Parcourir"
},
"date_picker": {
"toggle_button_aria_label_open": "Ouvrir le calendrier",
"toggle_button_aria_label_close": "Fermer le calendrier",