diff --git a/.changeset/sweet-actors-behave.md b/.changeset/sweet-actors-behave.md new file mode 100644 index 0000000..fd4d34e --- /dev/null +++ b/.changeset/sweet-actors-behave.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add file uploader diff --git a/packages/react/src/components/Forms/FileUploader/DropZone.tsx b/packages/react/src/components/Forms/FileUploader/DropZone.tsx new file mode 100644 index 0000000..9bb96ba --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/DropZone.tsx @@ -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( + ( + { + multiple, + name, + state, + icon, + animateIcon, + successIcon, + uploadingIcon, + text, + bigText, + files, + onFilesChange, + children, + ...props + }: DropZoneProps, + ref + ) => { + const [dragActive, setDragActive] = useState(false); + const container = useRef(null); + const inputRef = useRef(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 ?? done; + } + if (state === "uploading") { + return React.cloneElement(uploadingIcon ?? , { + "aria-label": t("components.forms.file_uploader.uploading"), + }); + } + return icon ?? upload; + }; + + const renderCaption = () => { + if (state === "uploading") { + return t("components.forms.file_uploader.uploading"); + } + if (bigText) { + return bigText; + } + return ( + <> + {t("components.forms.file_uploader.caption")} + {t("components.forms.file_uploader.browse_files")} + + ); + }; + + return ( + + ); + } +); diff --git a/packages/react/src/components/Forms/FileUploader/FileUploaderMono.tsx b/packages/react/src/components/Forms/FileUploader/FileUploaderMono.tsx new file mode 100644 index 0000000..2b632ff --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/FileUploaderMono.tsx @@ -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( + 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 ?? delete, + true, + ]; + } + if (file) { + return [ + props.fileSelectedIcon ?? ( + download + ), + false, + ]; + } + return [props.icon, true]; + }, [file, hoverDelete]); + + const deleteFile = (e: React.MouseEvent) => { + setFile(undefined); + // This is to prevent opening the browse file window. + e.preventDefault(); + setHoverDelete(false); + }; + + useEffect(() => { + props.onFilesChange?.({ target: { value: file ? [file] : [] } }); + }, [file]); + + return ( + <> + setFile(e.target.value[0])} + icon={icon} + animateIcon={animateIcon} + ref={ref} + > + {file && ( + <> +
{file.name}
+ {/* 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 */} +
{ + setHoverDelete(true); + }} + onMouseLeave={() => setHoverDelete(false)} + > + {t("components.forms.file_uploader.delete_file")} +
+ + )} +
+ {file && ( + <> + {/* This one is for a11y purposes. */} + + + )} + + ); +}); diff --git a/packages/react/src/components/Forms/FileUploader/FileUploaderMulti.tsx b/packages/react/src/components/Forms/FileUploader/FileUploaderMulti.tsx new file mode 100644 index 0000000..82e98ff --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/FileUploaderMulti.tsx @@ -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(fakeDefaultFiles || []); + + useEffect(() => { + props.onFilesChange?.({ target: { value: files } }); + }, [files]); + + return ( + <> + setFiles(e.target.value)} + animateIcon={true} + ref={ref} + /> + {files.length > 0 && ( +
+ {files.map((file) => ( +
+
{file.name}
+
+ {formatBytes(file.size)} +
+
+ ))} +
+ )} + + ); +}); diff --git a/packages/react/src/components/Forms/FileUploader/index.mdx b/packages/react/src/components/Forms/FileUploader/index.mdx new file mode 100644 index 0000000..ad7acaf --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/index.mdx @@ -0,0 +1,123 @@ +import { ArgTypes, Canvas, Meta, Source, Story } from '@storybook/blocks'; +import * as Stories from './index.stories'; +import { FileUploader } from './index'; + + + +# FileUploader + +Cunningham provides a file uploader component that you can use in your forms. + + + + + + + + +## Multi + +The file uploader comes with a multi version to handle multiple files. + + + + + + +## States + +You can use the following props to change the state of the FileUploader component by using the `state` props. + + + + + + + + + + + + + +## Texts + +You can customize displayed texts by using `bigText` and `text` props. + + + + + + + + + +## 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. + + + + + + + + + + + +## 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. + + + + + +## Props + +You can use all the props of the native html `` element props plus the following. + + + +## 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) diff --git a/packages/react/src/components/Forms/FileUploader/index.scss b/packages/react/src/components/Forms/FileUploader/index.scss new file mode 100644 index 0000000..f4e1064 --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/index.scss @@ -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); + } + } + } + } + } + } +} diff --git a/packages/react/src/components/Forms/FileUploader/index.spec.tsx b/packages/react/src/components/Forms/FileUploader/index.spec.tsx new file mode 100644 index 0000000..774f826 --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/index.spec.tsx @@ -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("", () => { + describe("Mono", () => { + it("should select a file and display its name", async () => { + render( + + + + ); + 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( + + + + ); + 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( + + + + ); + + 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( + + + + ); + expect( + document.querySelector(".c__file-uploader--uploading") + ).toBeInTheDocument(); + screen.getByText("Uploading..."); + screen.getByRole("status", { name: "Uploading..." }); + }); + + it("should be in state=success", async () => { + render( + + + + ); + 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( + + + + ); + + // 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( + + + + ); + screen.getByText("JPG, PNG or GIF - Max file size 2MB"); + }); + + it("should display custom icon, fileSelectedIcon, deleteIcon", async () => { + render( + + custom_icon} + fileSelectedIcon={file_selected_custom_icon} + deleteIcon={delete_custom_icon} + /> + + ); + 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( + + custom_success_icon} + state="success" + /> + + ); + screen.getByText("custom_success_icon"); + }); + + it("should display custom uploadingIcon", async () => { + render( + + custom_uploading_icon} + state="uploading" + /> + + ); + screen.getByText("custom_uploading_icon"); + }); + + it("can be reset in a controlled way and triggers onChange", async () => { + const Wrapper = () => { + const ref = useRef(null); + const [value, setValue] = useState([]); + return ( + +
+
Value: {value.map((file) => file.name).join(", ")}|
+ setValue(e.target.value)} + ref={ref} + /> + +
+
+ ); + }; + render(); + 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( + + + + ); + 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( + + + + ); + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + expect( + document.querySelector(".c__file-uploader--success") + ).toBeInTheDocument(); + expect(document.querySelector(".material-icons")?.textContent).toContain( + "done" + ); + }); + + it("should be in state=error", async () => { + render( + + + + ); + + // 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( + + + + ); + 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(null); + const [value, setValue] = useState([]); + return ( + +
+
Value: {value.map((file) => file.name).join(", ")}|
+ setValue(e.target.value)} + ref={ref} + multiple={true} + /> + +
+
+ ); + }; + render(); + 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: |"); + }); + }); +}); diff --git a/packages/react/src/components/Forms/FileUploader/index.stories.tsx b/packages/react/src/components/Forms/FileUploader/index.stories.tsx new file mode 100644 index 0000000..24b72c7 --- /dev/null +++ b/packages/react/src/components/Forms/FileUploader/index.stories.tsx @@ -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 = (args) => ( + + + +); + +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 = { + 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: add_box, + fileSelectedIcon: attach_file, + deleteIcon: backspace, + }, +}; + +export const MonoCustomIconsSuccess = { + args: { + text: "JPG, PNG or GIF - Max file size 2MB", + state: "success", + successIcon: verified, + }, +}; + +export const MonoCustomIconsUploading = { + args: { + text: "JPG, PNG or GIF - Max file size 2MB", + state: "uploading", + uploadingIcon: update, + }, +}; + +export const MonoStatesShowcase = { + render: () => { + const stepToProps: Record = { + 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 ( +