From 7f00bd76be72fd19340fdba247d95d2cf0cd097e Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Fri, 12 Feb 2021 14:09:51 -0600 Subject: [PATCH 01/10] WIP FileInput --- .../forms/FileInput/FileInput.stories.tsx | 17 ++ .../forms/FileInput/FileInput.test.tsx | 166 ++++++++++++++++++ src/components/forms/FileInput/FileInput.tsx | 71 ++++++++ 3 files changed, 254 insertions(+) create mode 100644 src/components/forms/FileInput/FileInput.stories.tsx create mode 100644 src/components/forms/FileInput/FileInput.test.tsx create mode 100644 src/components/forms/FileInput/FileInput.tsx diff --git a/src/components/forms/FileInput/FileInput.stories.tsx b/src/components/forms/FileInput/FileInput.stories.tsx new file mode 100644 index 0000000000..65e4d0ff2b --- /dev/null +++ b/src/components/forms/FileInput/FileInput.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import { FileInput } from './FileInput' + +export default { + title: 'Components/Form controls/File input', + component: FileInput, +} + +const testProps = { + id: 'testFile', + name: 'testFile', +} + +export const singleFileInput = (): React.ReactElement => ( + +) diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx new file mode 100644 index 0000000000..8f46dfc527 --- /dev/null +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' + +import { FileInput } from './FileInput' + +/** + * TEST CASES + * - single file + * - restrict file types + * - accepts images + * - accepts multiple files + * - error + * - disabled/enabled - DONE + * - other input props (required, aria-describedby) + * + * renders: + * - wrapper - DONE + * - input - DONE + * - droptarget - DONE + * - box - DONE + * - instructions - DONE + * + * features: + * - makeSafeForID util fn + * - modify drop instructions for IE11/Edge - DONE + * - removeOldPreviews: + * - reset previews/heading/error message + * - prevent invalid files: + * - reset invalid class + * - if accepted files, check if all files are allowed + * - if any files are not allowed: + * - remove old previews + * - reset value and display error UI, stop event + * - onChange handler: + * - remove old previews + * - FileReader, onloadstart/onloadend events to show previews + * - display heading + * + * event handlers: + * - drag class added on drag over - DONE + * - drag class removed on drag leave - DONE + * - drop handler prevents invalid files + * - drop handler removes drag class + * - on change event handler + * + * other examples: + * - async upload? onDrop/onChange prop + */ + +describe('FileInput component', () => { + const testProps = { + id: 'testFile', + name: 'testFile', + } + + it('renders without errors', () => { + const { getByTestId } = render() + expect(getByTestId('file-input')).toBeInTheDocument() + expect(getByTestId('file-input')).toHaveClass('usa-file-input') + }) + + it('renders a file input element that receives the name and id props', () => { + const { getByTestId } = render() + const inputEl = getByTestId('file-input-input') + expect(inputEl).toBeInstanceOf(HTMLInputElement) + expect(inputEl).toHaveAttribute('type', 'file') + expect(inputEl).toHaveAttribute('name', 'testFile') + expect(inputEl).toHaveAttribute('id', 'testFile') + expect(inputEl).toHaveClass('usa-file-input__input') + }) + + it('renders a drop target, box, and instructions text', () => { + const { getByTestId } = render() + expect(getByTestId('file-input-box')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('file-input-box')).toHaveClass('usa-file-input__box') + expect(getByTestId('file-input-droptarget')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('file-input-droptarget')).toHaveClass( + 'usa-file-input__target' + ) + expect(getByTestId('file-input-instructions')).toBeInstanceOf( + HTMLDivElement + ) + expect(getByTestId('file-input-instructions')).toHaveClass( + 'usa-file-input__instructions' + ) + expect(getByTestId('file-input-instructions')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + expect(getByTestId('file-input-instructions')).toHaveTextContent( + /Drag file here or choose from folder/i + ) + }) + + it('does not display drag text if on IE11', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation( + () => + 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko' + ) + const { getByTestId } = render() + + expect(getByTestId('file-input-instructions')).not.toHaveTextContent( + /Drag file here or choose from folder/i + ) + expect(getByTestId('file-input-instructions')).toHaveTextContent( + /choose from folder/i + ) + jest.restoreAllMocks() + }) + + it('does not display drag text if on Edge', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation( + () => + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.19582' + ) + const { getByTestId } = render() + + expect(getByTestId('file-input-instructions')).not.toHaveTextContent( + /Drag file here or choose from folder/i + ) + expect(getByTestId('file-input-instructions')).toHaveTextContent( + /choose from folder/i + ) + jest.restoreAllMocks() + }) + + describe('when disabled', () => { + const disabledProps = { ...testProps, disabled: true } + it('the input element is disabled', () => { + const { getByTestId } = render() + expect(getByTestId('file-input-input')).toBeDisabled() + }) + + it('the wrapper element is disabled', () => { + const { getByTestId } = render() + expect(getByTestId('file-input')).toHaveClass('usa-file-input--disabled') + expect(getByTestId('file-input')).toHaveAttribute('aria-disabled', 'true') + }) + }) + + describe('drag and drop', () => { + it('toggles the drag class when dragging over and leaving the target element', () => { + const { getByTestId } = render() + const targetEl = getByTestId('file-input-droptarget') + fireEvent.dragOver(targetEl) + expect(targetEl).toHaveClass('usa-file-input--drag') + fireEvent.dragLeave(targetEl) + expect(targetEl).not.toHaveClass('usa-file-input--drag') + }) + }) + + describe('when it accepts multiple files', () => { + const multipleFilesProps = { ...testProps, multiple: true } + + it('the instructions text reflects that multiple files can be selected', () => { + const { getByTestId } = render() + expect(getByTestId('file-input-instructions')).toHaveTextContent( + /Drag files here or choose from folder/i + ) + }) + }) +}) diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx new file mode 100644 index 0000000000..df06d17c13 --- /dev/null +++ b/src/components/forms/FileInput/FileInput.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react' +import classnames from 'classnames' + +interface FileInputProps { + id: string + name: string + disabled?: boolean + multiple?: boolean +} + +export const FileInput = ( + props: FileInputProps & JSX.IntrinsicElements['input'] +): React.ReactElement => { + const { name, id, disabled, multiple, className, ...inputProps } = props + const [isDragging, setIsDragging] = useState(false) + + const fileInputClasses = classnames( + 'usa-file-input', + { + 'usa-file-input--disabled': disabled, + }, + className + ) + + const targetClasses = classnames('usa-file-input__target', { + 'usa-file-input--drag': isDragging, + }) + + const hideDragText = + /rv:11.0/i.test(navigator.userAgent) || + /Edge\/\d./i.test(navigator.userAgent) + + const dragText = multiple ? 'Drag files here or ' : 'Drag file here or ' + + // Event handlers + const handleDragOver = (): void => setIsDragging(true) + const handleDragLeave = (): void => setIsDragging(false) + + return ( +
+
+ +
+ +
+
+ ) +} From 9345b85b8c1c22a5ebf464e8fdf37975033a9f45 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 16 Feb 2021 09:39:28 -0600 Subject: [PATCH 02/10] Working on FilePreview --- .../forms/FileInput/FileInput.test.tsx | 37 ++++++++ src/components/forms/FileInput/FileInput.tsx | 24 ++++- .../forms/FileInput/FilePreview.stories.tsx | 61 ++++++++++++ .../forms/FileInput/FilePreview.test.tsx | 94 +++++++++++++++++++ .../forms/FileInput/FilePreview.tsx | 61 ++++++++++++ src/components/forms/FileInput/constants.ts | 2 + src/components/forms/FileInput/utils.ts | 13 +++ 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 src/components/forms/FileInput/FilePreview.stories.tsx create mode 100644 src/components/forms/FileInput/FilePreview.test.tsx create mode 100644 src/components/forms/FileInput/FilePreview.tsx create mode 100644 src/components/forms/FileInput/constants.ts create mode 100644 src/components/forms/FileInput/utils.ts diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index 8f46dfc527..3108afe523 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { FileInput } from './FileInput' +import userEvent from '@testing-library/user-event' /** * TEST CASES @@ -53,6 +54,14 @@ describe('FileInput component', () => { name: 'testFile', } + const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { + type: 'text/plain', + }) + + const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { + type: 'image/png', + }) + it('renders without errors', () => { const { getByTestId } = render() expect(getByTestId('file-input')).toBeInTheDocument() @@ -151,6 +160,34 @@ describe('FileInput component', () => { fireEvent.dragLeave(targetEl) expect(targetEl).not.toHaveClass('usa-file-input--drag') }) + + it('removes the drag class when dropping over the target element', () => { + const { getByTestId } = render() + const targetEl = getByTestId('file-input-droptarget') + fireEvent.dragOver(targetEl) + expect(targetEl).toHaveClass('usa-file-input--drag') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_TEXT_FILE], + }, + }) + expect(targetEl).not.toHaveClass('usa-file-input--drag') + }) + }) + + describe('uploading files', () => { + it('renders a preview when a single file is chosen', () => { + const { getByTestId } = render() + const inputEl = getByTestId('file-input-input') + userEvent.upload(inputEl, TEST_PNG_FILE) + expect(getByTestId('file-input-preview')).toBeInTheDocument() + }) + + it.skip('renders a preview for each file when multiple files are chosen', () => { + const { getByTestId } = render() + const inputEl = getByTestId('file-input-input') + userEvent.upload(inputEl, [TEST_PNG_FILE, TEST_TEXT_FILE]) + }) }) describe('when it accepts multiple files', () => { diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index df06d17c13..bd68f0fa1c 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react' import classnames from 'classnames' +import { FilePreview } from './FilePreview' +import { makeSafeForID } from './utils' + interface FileInputProps { id: string name: string @@ -13,6 +16,7 @@ export const FileInput = ( ): React.ReactElement => { const { name, id, disabled, multiple, className, ...inputProps } = props const [isDragging, setIsDragging] = useState(false) + const [files, setFiles] = useState(null) const fileInputClasses = classnames( 'usa-file-input', @@ -32,9 +36,24 @@ export const FileInput = ( const dragText = multiple ? 'Drag files here or ' : 'Drag file here or ' + const filePreviews = [] + if (files) { + for (let i = 0; i < files?.length; i++) { + const imageId = makeSafeForID(files[i].name) + const key = `filePreview_${imageId}` + filePreviews.push( + + ) + } + } + // Event handlers const handleDragOver = (): void => setIsDragging(true) const handleDragLeave = (): void => setIsDragging(false) + const handleDrop = (): void => setIsDragging(false) + const handleChange = (e: React.ChangeEvent): void => { + setFiles(e.target?.files) + } return (
+ onDragLeave={handleDragLeave} + onDrop={handleDrop}>
choose from folder
+ {filePreviews}
diff --git a/src/components/forms/FileInput/FilePreview.stories.tsx b/src/components/forms/FileInput/FilePreview.stories.tsx new file mode 100644 index 0000000000..050665fe02 --- /dev/null +++ b/src/components/forms/FileInput/FilePreview.stories.tsx @@ -0,0 +1,61 @@ +import React from 'react' + +import { FilePreview } from './FilePreview' + +export default { + title: 'Components/Form controls/File input/File preview', + component: FilePreview, +} + +const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { + type: 'text/plain', +}) + +const TEST_PDF_FILE = new File(['Test PDF File'], 'testFile.pdf', { + type: 'application/pdf', +}) + +const TEST_DOC_FILE = new File(['Test doc File'], 'testFile.doc', { + type: 'application/msword', +}) + +const TEST_XLS_FILE = new File(['Test xls File'], 'testFile.xls', { + type: 'application/vnd.ms-excel', +}) + +const TEST_VIDEO_FILE = new File(['Test video File'], 'testFile.mp4', { + type: 'video/mp4', +}) + +const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { + type: 'image/png', +}) + +const testProps = { + imageId: 'testImageId_12345', + file: TEST_TEXT_FILE, +} + +export const loadingPreview = (): React.ReactElement => ( + +) + +export const pdfPreview = (): React.ReactElement => ( + +) + +export const docPreview = (): React.ReactElement => ( + +) + +export const xlsPreview = (): React.ReactElement => ( + +) + +export const videoPreview = (): React.ReactElement => ( + +) + +export const imagePreview = (): React.ReactElement => ( + +) diff --git a/src/components/forms/FileInput/FilePreview.test.tsx b/src/components/forms/FileInput/FilePreview.test.tsx new file mode 100644 index 0000000000..f57e60e9a5 --- /dev/null +++ b/src/components/forms/FileInput/FilePreview.test.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' + +import { FilePreview } from './FilePreview' +import { SPACER_GIF } from './constants' + +const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { + type: 'text/plain', +}) + +const TEST_PDF_FILE = new File(['Test PDF File'], 'testFile.pdf', { + type: 'application/pdf', +}) + +const INVALID_TEST_PDF_FILE = new File([], 'testFile.pdf') + +const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { + type: 'image/png', +}) + +describe('FilePreview component', () => { + const testProps = { + imageId: 'testImageId_12345', + file: TEST_TEXT_FILE, + } + + it('renders without errors', async () => { + const { getByTestId } = await waitFor(() => + render() + ) + expect(getByTestId('file-input-preview')).toBeInTheDocument() + expect(getByTestId('file-input-preview')).toHaveClass( + 'usa-file-input__preview' + ) + expect(getByTestId('file-input-preview')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + expect(getByTestId('file-input-preview')).toHaveTextContent( + testProps.file.name + ) + }) + + it('renders a preview image', async () => { + const { getByTestId } = await waitFor(() => + render() + ) + const imageEl = getByTestId('file-input-preview-image') + expect(imageEl).toBeInstanceOf(HTMLImageElement) + expect(imageEl).toHaveAttribute('id', testProps.imageId) + expect(imageEl).toHaveClass('usa-file-input__preview-image') + }) + + describe('while the file is loading', () => { + it('renders a loading image', async () => { + const { getByTestId } = await waitFor(() => + render() + ) + const imageEl = getByTestId('file-input-preview-image') + expect(imageEl).toHaveClass('is-loading') + expect(imageEl).toHaveAttribute('src', SPACER_GIF) + }) + }) + + describe('when the file is done loading', () => { + it('renders the file preview image and removes the loading class', async () => { + const { getByTestId } = await waitFor(() => + render() + ) + + const expectedSrc = 'data:text/plain;base64,VGVzdCBGaWxlIENvbnRlbnRz' + + const imageEl = getByTestId('file-input-preview-image') + await waitFor(() => expect(imageEl).not.toHaveClass('is-loading')) + expect(imageEl).toHaveAttribute('src', expectedSrc) + }) + + // TODO - force an image error on load? + describe.skip('for a PDF file', () => { + it('shows the PDF generic preview', async () => { + const { getByTestId } = await waitFor(() => + render( + + ) + ) + + const imageEl = getByTestId('file-input-preview-image') + await waitFor(() => expect(imageEl).not.toHaveClass('is-loading')) + await waitFor(() => expect(imageEl).toHaveAttribute('src', SPACER_GIF)) + expect(imageEl).toHaveClass('usa-file-input__preview-image--pdf') + }) + }) + }) +}) diff --git a/src/components/forms/FileInput/FilePreview.tsx b/src/components/forms/FileInput/FilePreview.tsx new file mode 100644 index 0000000000..4461d8bf2c --- /dev/null +++ b/src/components/forms/FileInput/FilePreview.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react' +import classnames from 'classnames' + +import { SPACER_GIF } from './constants' + +export const FilePreview = ({ + imageId, + file, +}: { + imageId: string + file: File +}): React.ReactElement => { + const fileReaderRef = useRef(new FileReader()) + const [isLoading, setIsLoading] = useState(true) + const [previewSrc, setPreviewSrc] = useState(SPACER_GIF) + const [showGenericPreview, setShowGenericPreview] = useState(false) + + fileReaderRef.current.onloadend = (): void => { + setIsLoading(false) + setPreviewSrc(fileReaderRef.current.result as string) + } + + useEffect(() => { + fileReaderRef.current.readAsDataURL(file) + + return (): void => { + fileReaderRef.current.onloadend = null + } + }, []) + + const { name } = file + + const onImageError = (): void => { + console.log('on img error', previewSrc) + setPreviewSrc(SPACER_GIF) + setShowGenericPreview(true) + } + + const imageClasses = classnames('usa-file-input__preview__image', { + 'is-loading': isLoading, + 'usa-file-input__preview__image--pdf': + showGenericPreview && name.indexOf('pdf') > 0, + }) + + return ( + + ) +} diff --git a/src/components/forms/FileInput/constants.ts b/src/components/forms/FileInput/constants.ts new file mode 100644 index 0000000000..fe3fe912aa --- /dev/null +++ b/src/components/forms/FileInput/constants.ts @@ -0,0 +1,2 @@ +export const SPACER_GIF = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' diff --git a/src/components/forms/FileInput/utils.ts b/src/components/forms/FileInput/utils.ts new file mode 100644 index 0000000000..26415cd202 --- /dev/null +++ b/src/components/forms/FileInput/utils.ts @@ -0,0 +1,13 @@ +/** + * Creates an ID name for each file that strips all invalid characters. + * @param {string} name - name of the file added to file input + * @returns {string} same characters as the name with invalid chars removed + */ +export const makeSafeForID = (name: string): string => { + return name.replace(/[^a-z0-9]/g, function replaceName(s) { + const c = s.charCodeAt(0) + if (c === 32) return '-' + if (c >= 65 && c <= 90) return `img_${s.toLowerCase()}` + return `__${c.toString(16).slice(-4)}` + }) +} From 7ed31f60c4141c728f2728364de47732f0fa5c3a Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 16 Feb 2021 12:13:22 -0600 Subject: [PATCH 03/10] Update USWDS to 2.8.1, implement different preview classes --- package.json | 4 +-- .../forms/FileInput/FileInput.test.tsx | 12 ++----- .../forms/FileInput/FilePreview.stories.tsx | 32 +++++-------------- .../forms/FileInput/FilePreview.test.tsx | 18 ++--------- .../forms/FileInput/FilePreview.tsx | 25 ++++++++++----- src/components/forms/FileInput/constants.ts | 25 +++++++++++++++ yarn.lock | 18 +++++------ 7 files changed, 67 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index 119f2a01dd..f025745028 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "peerDependencies": { "react": "^16.x || ^17.x", "react-dom": "^16.x || ^17.x", - "uswds": "2.8.0" + "uswds": "2.8.1" }, "devDependencies": { "@babel/core": "^7.10.5", @@ -124,7 +124,7 @@ "ts-jest": "^26.1.2", "typescript": "^3.8.0", "url-loader": "^4.0.0", - "uswds": "2.8.0", + "uswds": "2.8.1", "webpack": "^4.41.0", "webpack-cli": "^4.0.0" }, diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index 3108afe523..7007bd36c9 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -1,8 +1,9 @@ import React from 'react' import { fireEvent, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FileInput } from './FileInput' -import userEvent from '@testing-library/user-event' +import { TEST_TEXT_FILE, TEST_PNG_FILE } from './constants' /** * TEST CASES @@ -54,14 +55,6 @@ describe('FileInput component', () => { name: 'testFile', } - const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { - type: 'text/plain', - }) - - const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { - type: 'image/png', - }) - it('renders without errors', () => { const { getByTestId } = render() expect(getByTestId('file-input')).toBeInTheDocument() @@ -183,6 +176,7 @@ describe('FileInput component', () => { expect(getByTestId('file-input-preview')).toBeInTheDocument() }) + // TODO it.skip('renders a preview for each file when multiple files are chosen', () => { const { getByTestId } = render() const inputEl = getByTestId('file-input-input') diff --git a/src/components/forms/FileInput/FilePreview.stories.tsx b/src/components/forms/FileInput/FilePreview.stories.tsx index 050665fe02..90af8451e8 100644 --- a/src/components/forms/FileInput/FilePreview.stories.tsx +++ b/src/components/forms/FileInput/FilePreview.stories.tsx @@ -1,36 +1,20 @@ import React from 'react' import { FilePreview } from './FilePreview' +import { + TEST_TEXT_FILE, + TEST_PDF_FILE, + TEST_DOC_FILE, + TEST_XLS_FILE, + TEST_VIDEO_FILE, + TEST_PNG_FILE, +} from './constants' export default { title: 'Components/Form controls/File input/File preview', component: FilePreview, } -const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { - type: 'text/plain', -}) - -const TEST_PDF_FILE = new File(['Test PDF File'], 'testFile.pdf', { - type: 'application/pdf', -}) - -const TEST_DOC_FILE = new File(['Test doc File'], 'testFile.doc', { - type: 'application/msword', -}) - -const TEST_XLS_FILE = new File(['Test xls File'], 'testFile.xls', { - type: 'application/vnd.ms-excel', -}) - -const TEST_VIDEO_FILE = new File(['Test video File'], 'testFile.mp4', { - type: 'video/mp4', -}) - -const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { - type: 'image/png', -}) - const testProps = { imageId: 'testImageId_12345', file: TEST_TEXT_FILE, diff --git a/src/components/forms/FileInput/FilePreview.test.tsx b/src/components/forms/FileInput/FilePreview.test.tsx index f57e60e9a5..1a38522936 100644 --- a/src/components/forms/FileInput/FilePreview.test.tsx +++ b/src/components/forms/FileInput/FilePreview.test.tsx @@ -1,23 +1,11 @@ import React from 'react' -import { fireEvent, render, waitFor } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import { FilePreview } from './FilePreview' -import { SPACER_GIF } from './constants' - -const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { - type: 'text/plain', -}) - -const TEST_PDF_FILE = new File(['Test PDF File'], 'testFile.pdf', { - type: 'application/pdf', -}) +import { SPACER_GIF, TEST_TEXT_FILE } from './constants' const INVALID_TEST_PDF_FILE = new File([], 'testFile.pdf') -const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { - type: 'image/png', -}) - describe('FilePreview component', () => { const testProps = { imageId: 'testImageId_12345', @@ -75,7 +63,7 @@ describe('FilePreview component', () => { expect(imageEl).toHaveAttribute('src', expectedSrc) }) - // TODO - force an image error on load? + // TODO - how to force an image error on load? test each file type class describe.skip('for a PDF file', () => { it('shows the PDF generic preview', async () => { const { getByTestId } = await waitFor(() => diff --git a/src/components/forms/FileInput/FilePreview.tsx b/src/components/forms/FileInput/FilePreview.tsx index 4461d8bf2c..c2f6d46881 100644 --- a/src/components/forms/FileInput/FilePreview.tsx +++ b/src/components/forms/FileInput/FilePreview.tsx @@ -15,12 +15,12 @@ export const FilePreview = ({ const [previewSrc, setPreviewSrc] = useState(SPACER_GIF) const [showGenericPreview, setShowGenericPreview] = useState(false) - fileReaderRef.current.onloadend = (): void => { - setIsLoading(false) - setPreviewSrc(fileReaderRef.current.result as string) - } - useEffect(() => { + fileReaderRef.current.onloadend = (): void => { + setIsLoading(false) + setPreviewSrc(fileReaderRef.current.result as string) + } + fileReaderRef.current.readAsDataURL(file) return (): void => { @@ -36,10 +36,19 @@ export const FilePreview = ({ setShowGenericPreview(true) } - const imageClasses = classnames('usa-file-input__preview__image', { + const isPDF = name.indexOf('.pdf') > 0 + const isWord = name.indexOf('.doc') > 0 || name.indexOf('.pages') > 0 + const isVideo = name.indexOf('.mov') > 0 || name.indexOf('.mp4') > 0 + const isExcel = name.indexOf('.xls') > 0 || name.indexOf('.numbers') > 0 + const isGeneric = !isPDF && !isWord && !isVideo && !isExcel + + const imageClasses = classnames('usa-file-input__preview-image', { 'is-loading': isLoading, - 'usa-file-input__preview__image--pdf': - showGenericPreview && name.indexOf('pdf') > 0, + 'usa-file-input__preview-image--pdf': showGenericPreview && isPDF, + 'usa-file-input__preview-image--word': showGenericPreview && isWord, + 'usa-file-input__preview-image--video': showGenericPreview && isVideo, + 'usa-file-input__preview-image--excel': showGenericPreview && isExcel, + 'usa-file-input__preview-image--generic': showGenericPreview && isGeneric, }) return ( diff --git a/src/components/forms/FileInput/constants.ts b/src/components/forms/FileInput/constants.ts index fe3fe912aa..81d1a0cab0 100644 --- a/src/components/forms/FileInput/constants.ts +++ b/src/components/forms/FileInput/constants.ts @@ -1,2 +1,27 @@ export const SPACER_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + +// Test files +export const TEST_TEXT_FILE = new File(['Test File Contents'], 'testFile.txt', { + type: 'text/plain', +}) + +export const TEST_PDF_FILE = new File(['Test PDF File'], 'testFile.pdf', { + type: 'application/pdf', +}) + +export const TEST_DOC_FILE = new File(['Test doc File'], 'testFile.doc', { + type: 'application/msword', +}) + +export const TEST_XLS_FILE = new File(['Test xls File'], 'testFile.xls', { + type: 'application/vnd.ms-excel', +}) + +export const TEST_VIDEO_FILE = new File(['Test video File'], 'testFile.mp4', { + type: 'video/mp4', +}) + +export const TEST_PNG_FILE = new File(['Test PNG Image'], 'testFile.png', { + type: 'image/png', +}) diff --git a/yarn.lock b/yarn.lock index af15880f09..1af6043df1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6151,10 +6151,10 @@ electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.634: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.645.tgz#c0b269ae2ecece5aedc02dd4586397d8096affb1" integrity sha512-T7mYop3aDpRHIQaUYcmzmh6j9MAe560n6ukqjJMbVC6bVTau7dSpvB18bcsBPPtOSe10cKxhJFtlbEzLa0LL1g== -elem-dataset@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/elem-dataset/-/elem-dataset-1.1.1.tgz#18f07fa7fc71ebd49b0f9f63819cb03c8276577a" - integrity sha1-GPB/p/xx69SbD59jgZywPIJ2V3o= +elem-dataset@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/elem-dataset/-/elem-dataset-2.0.0.tgz#4ed8b2b0217898bdf78c1a01b4eb722a1c89e799" + integrity sha512-e7gieGopWw5dMdEgythH3lUS7nMizutPDTtkzfQW/q2gCvFnACyNnK3ytCncAXKxdBXQWcXeKaYTTODiMnp8mw== element-closest@^2.0.1: version "2.0.2" @@ -14475,15 +14475,15 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -uswds@2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.8.0.tgz#635e829555a359562f28ab51e1b6c983f149f739" - integrity sha512-LI7ZNbh823ehtThvebwQ5eHNKGY791vcT5d3T9gJ+RY511HgstWLRSEEso7YO/ifjoV/FwuTAtDwQqt7zsXRvA== +uswds@2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/uswds/-/uswds-2.8.1.tgz#937505ad8f7be4c131e6d4e01d0128cf0995a49c" + integrity sha512-x7RkaVFVuRxptsuUZka6Mc+wUUL98keorOUFVxrBIgd3KV18upuF5V7osm9sN/q6bGvwh2zJyfONETLUU8Eemg== dependencies: classlist-polyfill "^1.0.3" del "^5.1.0" domready "^1.0.8" - elem-dataset "^1.1.1" + elem-dataset "^2.0.0" lodash.debounce "^4.0.7" object-assign "^4.1.1" receptor "^1.0.0" From e15617fe5a459c6be8e6fb59a0d48fcad8d8edd2 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 16 Feb 2021 12:41:41 -0600 Subject: [PATCH 04/10] Handle multiple files, update header text --- .../forms/FileInput/FileInput.stories.tsx | 4 ++ .../forms/FileInput/FileInput.test.tsx | 59 +++++++++++++++---- src/components/forms/FileInput/FileInput.tsx | 22 ++++++- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/components/forms/FileInput/FileInput.stories.tsx b/src/components/forms/FileInput/FileInput.stories.tsx index 65e4d0ff2b..93303b38d2 100644 --- a/src/components/forms/FileInput/FileInput.stories.tsx +++ b/src/components/forms/FileInput/FileInput.stories.tsx @@ -15,3 +15,7 @@ const testProps = { export const singleFileInput = (): React.ReactElement => ( ) + +export const multipleFilesInput = (): React.ReactElement => ( + +) diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index 7007bd36c9..c4232b8807 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FileInput } from './FileInput' -import { TEST_TEXT_FILE, TEST_PNG_FILE } from './constants' +import { TEST_TEXT_FILE, TEST_PNG_FILE, TEST_XLS_FILE } from './constants' /** * TEST CASES @@ -23,7 +23,7 @@ import { TEST_TEXT_FILE, TEST_PNG_FILE } from './constants' * - instructions - DONE * * features: - * - makeSafeForID util fn + * - makeSafeForID util fn - DONE * - modify drop instructions for IE11/Edge - DONE * - removeOldPreviews: * - reset previews/heading/error message @@ -34,16 +34,16 @@ import { TEST_TEXT_FILE, TEST_PNG_FILE } from './constants' * - remove old previews * - reset value and display error UI, stop event * - onChange handler: - * - remove old previews - * - FileReader, onloadstart/onloadend events to show previews - * - display heading + * - remove old previews - reset error + * - FileReader, onloadstart/onloadend events to show previews - DONE + * - display heading - DONE * * event handlers: * - drag class added on drag over - DONE * - drag class removed on drag leave - DONE * - drop handler prevents invalid files - * - drop handler removes drag class - * - on change event handler + * - drop handler removes drag class - DONE + * - on change event handler - DONE * * other examples: * - async upload? onDrop/onChange prop @@ -169,18 +169,55 @@ describe('FileInput component', () => { }) describe('uploading files', () => { - it('renders a preview when a single file is chosen', () => { + it('renders a preview and header text when a single file is chosen', () => { const { getByTestId } = render() const inputEl = getByTestId('file-input-input') userEvent.upload(inputEl, TEST_PNG_FILE) expect(getByTestId('file-input-preview')).toBeInTheDocument() + expect(getByTestId('file-input-instructions')).toHaveClass('display-none') + const previewHeading = getByTestId('file-input-preview-heading') + expect(previewHeading).toHaveTextContent('Selected file Change file') }) - // TODO - it.skip('renders a preview for each file when multiple files are chosen', () => { - const { getByTestId } = render() + it('renders a preview for each file and header text when multiple files are chosen', () => { + const { getByTestId, getAllByTestId } = render( + + ) + const inputEl = getByTestId('file-input-input') + userEvent.upload(inputEl, [TEST_PNG_FILE, TEST_TEXT_FILE]) + expect(getAllByTestId('file-input-preview')).toHaveLength(2) + expect(getByTestId('file-input-instructions')).toHaveClass('display-none') + const previewHeading = getByTestId('file-input-preview-heading') + expect(previewHeading).toHaveTextContent('2 files selected Change files') + }) + + it('only shows previews for the most recently selected files if files are selected multiple times', () => { + const { getByTestId, getAllByTestId, queryByTestId } = render( + + ) const inputEl = getByTestId('file-input-input') userEvent.upload(inputEl, [TEST_PNG_FILE, TEST_TEXT_FILE]) + let previews = getAllByTestId('file-input-preview') + expect(previews).toHaveLength(2) + expect(previews[0]).toHaveTextContent(TEST_PNG_FILE.name) + expect(previews[1]).toHaveTextContent(TEST_TEXT_FILE.name) + const previewHeading = getByTestId('file-input-preview-heading') + expect(previewHeading).toHaveTextContent('2 files selected Change files') + + // Change to 1 file + userEvent.upload(inputEl, [TEST_XLS_FILE]) + previews = getAllByTestId('file-input-preview') + expect(previews).toHaveLength(1) + expect(previews[0]).toHaveTextContent(TEST_XLS_FILE.name) + expect(previewHeading).toHaveTextContent('Selected file Change file') + + // Change to no files + userEvent.upload(inputEl, []) + expect(queryByTestId('file-input-preview')).not.toBeInTheDocument() + expect(previewHeading).not.toBeInTheDocument() + expect(getByTestId('file-input-instructions')).not.toHaveClass( + 'display-none' + ) }) }) diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index bd68f0fa1c..91c44546bf 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -47,6 +47,15 @@ export const FileInput = ( } } + const instructionClasses = classnames('usa-file-input__instructions', { + 'display-none': filePreviews.length > 0, + }) + + const previewHeaderText = + filePreviews.length > 1 + ? `${filePreviews.length} files selected` + : 'Selected file' + // Event handlers const handleDragOver = (): void => setIsDragging(true) const handleDragLeave = (): void => setIsDragging(false) @@ -66,9 +75,19 @@ export const FileInput = ( onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}> + {filePreviews.length > 0 && ( +
+ {previewHeaderText}{' '} + + Change file{filePreviews.length > 1 && 's'} + +
+ )} From 6ed59766d8798c5eb9856f6a51f11eb2679acda0 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 16 Feb 2021 16:03:39 -0600 Subject: [PATCH 05/10] Add file type validation --- .../forms/FileInput/FileInput.stories.tsx | 8 +++ .../forms/FileInput/FileInput.test.tsx | 69 +++++++++++++++++-- src/components/forms/FileInput/FileInput.tsx | 56 ++++++++++++++- .../forms/FileInput/FilePreview.tsx | 1 - 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/components/forms/FileInput/FileInput.stories.tsx b/src/components/forms/FileInput/FileInput.stories.tsx index 93303b38d2..0da72eac5e 100644 --- a/src/components/forms/FileInput/FileInput.stories.tsx +++ b/src/components/forms/FileInput/FileInput.stories.tsx @@ -19,3 +19,11 @@ export const singleFileInput = (): React.ReactElement => ( export const multipleFilesInput = (): React.ReactElement => ( ) + +export const disabled = (): React.ReactElement => ( + +) + +export const acceptTextAndPDF = (): React.ReactElement => ( + +) diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index c4232b8807..dfddd4455f 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -3,14 +3,19 @@ import { fireEvent, render } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FileInput } from './FileInput' -import { TEST_TEXT_FILE, TEST_PNG_FILE, TEST_XLS_FILE } from './constants' +import { + TEST_TEXT_FILE, + TEST_PNG_FILE, + TEST_XLS_FILE, + TEST_PDF_FILE, +} from './constants' /** * TEST CASES - * - single file - * - restrict file types + * - single file - DONE + * - restrict file types - DONE * - accepts images - * - accepts multiple files + * - accepts multiple files - DONE * - error * - disabled/enabled - DONE * - other input props (required, aria-describedby) @@ -46,6 +51,7 @@ import { TEST_TEXT_FILE, TEST_PNG_FILE, TEST_XLS_FILE } from './constants' * - on change event handler - DONE * * other examples: + * - custom handlers * - async upload? onDrop/onChange prop */ @@ -231,4 +237,59 @@ describe('FileInput component', () => { ) }) }) + + describe('when it only accepts certain file types', () => { + // TODO - try to make this testing better when adding custom drop/change handlers + it('accepts an uploaded file of an accepted type', () => { + const { getByTestId, queryByTestId } = render( + + ) + + const inputEl = getByTestId('file-input-input') as HTMLInputElement + expect(inputEl).toHaveAttribute('accept', '.pdf,.txt') + + const targetEl = getByTestId('file-input-droptarget') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_PDF_FILE], + }, + }) + // For some reason the simulated drop event does not trigger an onChange event + userEvent.upload(inputEl, TEST_PDF_FILE) + + expect(queryByTestId('file-input-error')).not.toBeInTheDocument() + expect(getByTestId('file-input-droptarget')).not.toHaveClass( + 'has-invalid-file' + ) + expect(getByTestId('file-input-preview')).toBeInTheDocument() + }) + + it('shows an error and clears the input if any files are not an accepted type', () => { + const { getByTestId, queryByTestId } = render( + + ) + + const inputEl = getByTestId('file-input-input') as HTMLInputElement + expect(inputEl).toHaveAttribute('accept', '.pdf,.txt') + + const targetEl = getByTestId('file-input-droptarget') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_PNG_FILE], + }, + }) + + expect(getByTestId('file-input-error')).toHaveTextContent( + 'This is not a valid file type' + ) + expect(getByTestId('file-input-error')).toHaveClass( + 'usa-file-input__accepted-files-message' + ) + expect(getByTestId('file-input-droptarget')).toHaveClass( + 'has-invalid-file' + ) + + expect(queryByTestId('file-input-preview')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index 91c44546bf..b887923197 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -9,13 +9,23 @@ interface FileInputProps { name: string disabled?: boolean multiple?: boolean + accept?: string } export const FileInput = ( props: FileInputProps & JSX.IntrinsicElements['input'] ): React.ReactElement => { - const { name, id, disabled, multiple, className, ...inputProps } = props + const { + name, + id, + disabled, + multiple, + className, + accept, + ...inputProps + } = props const [isDragging, setIsDragging] = useState(false) + const [showError, setShowError] = useState(false) const [files, setFiles] = useState(null) const fileInputClasses = classnames( @@ -28,6 +38,7 @@ export const FileInput = ( const targetClasses = classnames('usa-file-input__target', { 'usa-file-input--drag': isDragging, + 'has-invalid-file': showError, }) const hideDragText = @@ -56,11 +67,44 @@ export const FileInput = ( ? `${filePreviews.length} files selected` : 'Selected file' + const preventInvalidFiles = (e: React.DragEvent): void => { + setShowError(false) + + if (accept) { + const acceptedTypes = accept.split(',') + let allFilesAllowed = true + for (let i = 0; i < e.dataTransfer.files.length; i += 1) { + const file = e.dataTransfer.files[i] + if (allFilesAllowed) { + for (let j = 0; j < acceptedTypes.length; j += 1) { + const fileType = acceptedTypes[j] + allFilesAllowed = + file.name.indexOf(fileType) > 0 || + file.type.includes(fileType.replace(/\*/g, '')) + if (allFilesAllowed) break + } + } else break + } + + if (!allFilesAllowed) { + setFiles(null) + setShowError(true) + e.preventDefault() + e.stopPropagation() + } + } + } + // Event handlers const handleDragOver = (): void => setIsDragging(true) const handleDragLeave = (): void => setIsDragging(false) - const handleDrop = (): void => setIsDragging(false) + const handleDrop = (e: React.DragEvent): void => { + preventInvalidFiles(e) + setIsDragging(false) + } + const handleChange = (e: React.ChangeEvent): void => { + setShowError(false) setFiles(e.target?.files) } @@ -96,6 +140,13 @@ export const FileInput = ( {filePreviews}
+ {showError && ( +
+ This is not a valid file type. +
+ )} diff --git a/src/components/forms/FileInput/FilePreview.tsx b/src/components/forms/FileInput/FilePreview.tsx index c2f6d46881..2aa0bba081 100644 --- a/src/components/forms/FileInput/FilePreview.tsx +++ b/src/components/forms/FileInput/FilePreview.tsx @@ -31,7 +31,6 @@ export const FilePreview = ({ const { name } = file const onImageError = (): void => { - console.log('on img error', previewSrc) setPreviewSrc(SPACER_GIF) setShowGenericPreview(true) } From a843b08c5c298badfb2926be790248fd5980227d Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 16 Feb 2021 16:06:24 -0600 Subject: [PATCH 06/10] Add FileInput export --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index e124f36f77..fac10ac706 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { DatePicker } from './components/forms/DatePicker/DatePicker' export { Dropdown } from './components/forms/Dropdown/Dropdown' export { ErrorMessage } from './components/forms/ErrorMessage/ErrorMessage' export { Fieldset } from './components/forms/Fieldset/Fieldset' +export { FileInput } from './components/forms/FileInput/FileInput' export { Form } from './components/forms/Form/Form' export { FormGroup } from './components/forms/FormGroup/FormGroup' export { Label } from './components/forms/Label/Label' From 2b1aee1ef21cba7ce397f5a093d1fa48f663e077 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 23 Feb 2021 12:50:09 -0600 Subject: [PATCH 07/10] Clean up stories, add test for image input --- .../forms/FileInput/FileInput.stories.tsx | 98 ++++++++++++++++--- .../forms/FileInput/FileInput.test.tsx | 94 ++++++++++-------- src/components/forms/FileInput/FileInput.tsx | 12 ++- .../forms/FileInput/FilePreview.stories.tsx | 3 + 4 files changed, 153 insertions(+), 54 deletions(-) diff --git a/src/components/forms/FileInput/FileInput.stories.tsx b/src/components/forms/FileInput/FileInput.stories.tsx index 0da72eac5e..8f05183bf6 100644 --- a/src/components/forms/FileInput/FileInput.stories.tsx +++ b/src/components/forms/FileInput/FileInput.stories.tsx @@ -1,29 +1,105 @@ import React from 'react' import { FileInput } from './FileInput' +import { FormGroup } from '../FormGroup/FormGroup' +import { Label } from '../Label/Label' +import { ErrorMessage } from '../ErrorMessage/ErrorMessage' export default { title: 'Components/Form controls/File input', component: FileInput, -} - -const testProps = { - id: 'testFile', - name: 'testFile', + parameters: { + docs: { + description: { + component: ` +### USWDS 2.0 FileInput component +Source: https://designsystem.digital.gov/components/form-controls/#file-input +`, + }, + }, + }, } export const singleFileInput = (): React.ReactElement => ( - + + + + +) + +export const acceptTextAndPDF = (): React.ReactElement => ( + + + + Select PDF or TXT files + + + +) + +export const acceptImages = (): React.ReactElement => ( + + + + Select any type of image format + + + ) export const multipleFilesInput = (): React.ReactElement => ( - + + + + Select one or more files + + + ) -export const disabled = (): React.ReactElement => ( - +export const withError = (): React.ReactElement => ( +
+ + + + Select any valid file + + + Display a helpful error message + + + +
) -export const acceptTextAndPDF = (): React.ReactElement => ( - +export const disabled = (): React.ReactElement => ( + + + + ) diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index dfddd4455f..70eba2c82e 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -11,45 +11,6 @@ import { } from './constants' /** - * TEST CASES - * - single file - DONE - * - restrict file types - DONE - * - accepts images - * - accepts multiple files - DONE - * - error - * - disabled/enabled - DONE - * - other input props (required, aria-describedby) - * - * renders: - * - wrapper - DONE - * - input - DONE - * - droptarget - DONE - * - box - DONE - * - instructions - DONE - * - * features: - * - makeSafeForID util fn - DONE - * - modify drop instructions for IE11/Edge - DONE - * - removeOldPreviews: - * - reset previews/heading/error message - * - prevent invalid files: - * - reset invalid class - * - if accepted files, check if all files are allowed - * - if any files are not allowed: - * - remove old previews - * - reset value and display error UI, stop event - * - onChange handler: - * - remove old previews - reset error - * - FileReader, onloadstart/onloadend events to show previews - DONE - * - display heading - DONE - * - * event handlers: - * - drag class added on drag over - DONE - * - drag class removed on drag leave - DONE - * - drop handler prevents invalid files - * - drop handler removes drag class - DONE - * - on change event handler - DONE - * * other examples: * - custom handlers * - async upload? onDrop/onChange prop @@ -292,4 +253,59 @@ describe('FileInput component', () => { expect(queryByTestId('file-input-preview')).not.toBeInTheDocument() }) }) + + describe('when it only accepts image files', () => { + // TODO - try to make this testing better when adding custom drop/change handlers + it('accepts an image file', () => { + const { getByTestId, queryByTestId } = render( + + ) + + const inputEl = getByTestId('file-input-input') as HTMLInputElement + expect(inputEl).toHaveAttribute('accept', 'image/*') + + const targetEl = getByTestId('file-input-droptarget') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_PNG_FILE], + }, + }) + // For some reason the simulated drop event does not trigger an onChange event + userEvent.upload(inputEl, TEST_PNG_FILE) + + expect(queryByTestId('file-input-error')).not.toBeInTheDocument() + expect(getByTestId('file-input-droptarget')).not.toHaveClass( + 'has-invalid-file' + ) + expect(getByTestId('file-input-preview')).toBeInTheDocument() + }) + + it('shows an error and clears the input if any files are not images', () => { + const { getByTestId, queryByTestId } = render( + + ) + + const inputEl = getByTestId('file-input-input') as HTMLInputElement + expect(inputEl).toHaveAttribute('accept', 'image/*') + + const targetEl = getByTestId('file-input-droptarget') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_PDF_FILE], + }, + }) + + expect(getByTestId('file-input-error')).toHaveTextContent( + 'This is not a valid file type' + ) + expect(getByTestId('file-input-error')).toHaveClass( + 'usa-file-input__accepted-files-message' + ) + expect(getByTestId('file-input-droptarget')).toHaveClass( + 'has-invalid-file' + ) + + expect(queryByTestId('file-input-preview')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index b887923197..d6da75c0d2 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -50,10 +50,14 @@ export const FileInput = ( const filePreviews = [] if (files) { for (let i = 0; i < files?.length; i++) { - const imageId = makeSafeForID(files[i].name) + const imageId = makeSafeForID(files[parseInt(`${i}`)].name) const key = `filePreview_${imageId}` filePreviews.push( - + ) } } @@ -74,10 +78,10 @@ export const FileInput = ( const acceptedTypes = accept.split(',') let allFilesAllowed = true for (let i = 0; i < e.dataTransfer.files.length; i += 1) { - const file = e.dataTransfer.files[i] + const file = e.dataTransfer.files[parseInt(`${i}`)] if (allFilesAllowed) { for (let j = 0; j < acceptedTypes.length; j += 1) { - const fileType = acceptedTypes[j] + const fileType = acceptedTypes[parseInt(`${j}`)] allFilesAllowed = file.name.indexOf(fileType) > 0 || file.type.includes(fileType.replace(/\*/g, '')) diff --git a/src/components/forms/FileInput/FilePreview.stories.tsx b/src/components/forms/FileInput/FilePreview.stories.tsx index 90af8451e8..72d29a5c22 100644 --- a/src/components/forms/FileInput/FilePreview.stories.tsx +++ b/src/components/forms/FileInput/FilePreview.stories.tsx @@ -10,10 +10,13 @@ import { TEST_PNG_FILE, } from './constants' +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY export default { title: 'Components/Form controls/File input/File preview', component: FilePreview, } +*/ const testProps = { imageId: 'testImageId_12345', From 2ea048ab0c0d8487479848b755dbc92e29bb5fb6 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Tue, 23 Feb 2021 13:15:11 -0600 Subject: [PATCH 08/10] Expose custom event handlers and inputRef prop --- .../forms/FileInput/FileInput.stories.tsx | 41 ++++++++++++++++++- .../forms/FileInput/FileInput.test.tsx | 28 +++++++++++++ src/components/forms/FileInput/FileInput.tsx | 9 ++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/components/forms/FileInput/FileInput.stories.tsx b/src/components/forms/FileInput/FileInput.stories.tsx index 8f05183bf6..dbf0c2e34a 100644 --- a/src/components/forms/FileInput/FileInput.stories.tsx +++ b/src/components/forms/FileInput/FileInput.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState, useRef } from 'react' import { FileInput } from './FileInput' import { FormGroup } from '../FormGroup/FormGroup' @@ -8,6 +8,10 @@ import { ErrorMessage } from '../ErrorMessage/ErrorMessage' export default { title: 'Components/Form controls/File input', component: FileInput, + argTypes: { + onChange: { action: 'changed' }, + onDrop: { action: 'dropped' }, + }, parameters: { docs: { description: { @@ -103,3 +107,38 @@ export const disabled = (): React.ReactElement => ( ) + +export const withCustomHandlers = (argTypes): React.ReactElement => { + const [files, setFiles] = useState() + const fileInputRef = useRef() + + const handleChange = (e: React.ChangeEvent): void => { + argTypes.onChange(e) + setFiles(fileInputRef.current.files) + } + + const fileList = [] + for (let i = 0; i < files?.length; i++) { + fileList.push(
  • {files[i].name}
  • ) + } + + return ( + <> + + + + +

    {files?.length || 0} files added:

    +
      {fileList}
    + + ) +} diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index 70eba2c82e..a8e5fad823 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -308,4 +308,32 @@ describe('FileInput component', () => { expect(queryByTestId('file-input-preview')).not.toBeInTheDocument() }) }) + + it('implements an onChange handler when passed as a prop', () => { + const mockOnChange = jest.fn() + const { getByTestId } = render( + + ) + + const inputEl = getByTestId('file-input-input') as HTMLInputElement + userEvent.upload(inputEl, TEST_PNG_FILE) + + expect(mockOnChange).toHaveBeenCalled() + }) + + it('implements an onDrop handler when passed as a prop', () => { + const mockOnDrop = jest.fn() + const { getByTestId } = render( + + ) + + const targetEl = getByTestId('file-input-droptarget') + fireEvent.drop(targetEl, { + dataTransfer: { + files: [TEST_PDF_FILE], + }, + }) + + expect(mockOnDrop).toHaveBeenCalled() + }) }) diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index d6da75c0d2..cdfb3b8670 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -10,6 +10,9 @@ interface FileInputProps { disabled?: boolean multiple?: boolean accept?: string + onChange?: (e: React.ChangeEvent) => void + onDrop?: (e: React.DragEvent) => void + inputRef?: React.RefObject } export const FileInput = ( @@ -22,6 +25,9 @@ export const FileInput = ( multiple, className, accept, + onChange, + onDrop, + inputRef, ...inputProps } = props const [isDragging, setIsDragging] = useState(false) @@ -105,11 +111,13 @@ export const FileInput = ( const handleDrop = (e: React.DragEvent): void => { preventInvalidFiles(e) setIsDragging(false) + if (onDrop) onDrop(e) } const handleChange = (e: React.ChangeEvent): void => { setShowError(false) setFiles(e.target?.files) + if (onChange) onChange(e) } return ( @@ -153,6 +161,7 @@ export const FileInput = ( )} Date: Thu, 25 Feb 2021 15:17:16 -0600 Subject: [PATCH 09/10] Add FileInpute examples to Forms page, expose inputRef --- example/src/pages/Forms.tsx | 49 ++++++++++++++++++-- src/components/forms/FileInput/FileInput.tsx | 2 +- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/example/src/pages/Forms.tsx b/example/src/pages/Forms.tsx index aacdf22373..bceda08efd 100644 --- a/example/src/pages/Forms.tsx +++ b/example/src/pages/Forms.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { Field, Formik } from 'formik' import * as Yup from 'yup' import { @@ -12,6 +12,7 @@ import { ValidationChecklist, ValidationItem, DatePicker, + FileInput, } from '@trussworks/react-uswds' type FormValues = { @@ -44,6 +45,8 @@ const fruitOptions = Object.entries(fruits).map(([value, key]) => ({ })) const FormsPage = (): React.ReactElement => { + const fileInputRef = useRef(null) + return ( <>

    Forms Examples

    @@ -55,12 +58,14 @@ const FormsPage = (): React.ReactElement => { password: '', fruit: 'avocado', appointmentDate: '1/20/2021', + file: '', + attachments: [], }} validationSchema={FormSchema} onSubmit={(values, { setSubmitting }) => { + console.log('Submit form data:', values) setTimeout(() => { - alert(JSON.stringify(values, null, 2)) - + console.log('Submit complete!') setSubmitting(false) }, 400) }}> @@ -151,6 +156,44 @@ const FormsPage = (): React.ReactElement => { /> + + + { + const event = e as React.ChangeEvent + + if (event.target.files?.length) { + setFieldValue('file', event.target.files[0]) + } + }} + /> + + + + + { + const event = e as React.ChangeEvent + const files = [] + if (event.target.files?.length) { + for (let i = 0; i < event.target.files?.length; i++) { + files.push(event.target.files[i]) + } + } + setFieldValue('attachments', files) + }} + onDrop={(e: React.DragEvent): void => { + console.log('handle drop', e) + }} + inputRef={fileInputRef} + /> + + diff --git a/src/components/forms/FileInput/FileInput.tsx b/src/components/forms/FileInput/FileInput.tsx index cdfb3b8670..e655f6de12 100644 --- a/src/components/forms/FileInput/FileInput.tsx +++ b/src/components/forms/FileInput/FileInput.tsx @@ -10,7 +10,7 @@ interface FileInputProps { disabled?: boolean multiple?: boolean accept?: string - onChange?: (e: React.ChangeEvent) => void + onChange?: (e: React.ChangeEvent) => void onDrop?: (e: React.DragEvent) => void inputRef?: React.RefObject } From 49e5fd062609df3baf1267f66bff304b694b41bf Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Thu, 25 Feb 2021 16:44:59 -0600 Subject: [PATCH 10/10] Remove completed comments --- src/components/forms/FileInput/FileInput.test.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/forms/FileInput/FileInput.test.tsx b/src/components/forms/FileInput/FileInput.test.tsx index a8e5fad823..658e5ad48a 100644 --- a/src/components/forms/FileInput/FileInput.test.tsx +++ b/src/components/forms/FileInput/FileInput.test.tsx @@ -10,12 +10,6 @@ import { TEST_PDF_FILE, } from './constants' -/** - * other examples: - * - custom handlers - * - async upload? onDrop/onChange prop - */ - describe('FileInput component', () => { const testProps = { id: 'testFile',