From 2793c1da5b0922f71120184bcf7647f122599d64 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sat, 2 Sep 2023 21:16:42 +0800 Subject: [PATCH] Ensure 100% coverage on blob Cover all scenarios for FileSelectBox. --- categories/web/action/react/package.json | 2 +- categories/web/blob/react/package.json | 2 +- .../FileSelectBox/FileSelectBox.test.tsx | 387 ++++++++++++++++-- .../src/components/FileSelectBox/index.tsx | 199 +++++---- categories/web/choice/react/package.json | 2 +- categories/web/color/react/package.json | 2 +- categories/web/formatted/react/package.json | 2 +- categories/web/freeform/react/package.json | 2 +- categories/web/information/react/package.json | 2 +- categories/web/multichoice/react/package.json | 2 +- categories/web/navigation/react/package.json | 2 +- categories/web/number/react/package.json | 2 +- categories/web/temporal/react/package.json | 2 +- .../react-binary-data-canvas/package.json | 1 - packages/react-blob-previews/package.json | 1 - packages/react-refractor/package.json | 1 - packages/react-utils/package.json | 1 - packages/react-wavesurfer/package.json | 1 - pnpm-lock.yaml | 186 ++++----- 19 files changed, 555 insertions(+), 244 deletions(-) diff --git a/categories/web/action/react/package.json b/categories/web/action/react/package.json index 9bbbab4..5043c7d 100644 --- a/categories/web/action/react/package.json +++ b/categories/web/action/react/package.json @@ -23,7 +23,7 @@ "@vitest/coverage-v8": "^0.33.0", "eslint": "^8.46.0", "eslint-config-lxsmnsyc": "^0.5.0", - "jsdom": "^21.1.0", + "jsdom": "^22.1.0", "pridepack": "2.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/categories/web/blob/react/package.json b/categories/web/blob/react/package.json index 816b394..d095d59 100644 --- a/categories/web/blob/react/package.json +++ b/categories/web/blob/react/package.json @@ -23,7 +23,7 @@ "@vitest/coverage-v8": "^0.33.0", "eslint": "^8.46.0", "eslint-config-lxsmnsyc": "^0.5.0", - "jsdom": "^21.1.0", + "jsdom": "^22.1.0", "pridepack": "2.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/categories/web/blob/react/src/components/FileSelectBox/FileSelectBox.test.tsx b/categories/web/blob/react/src/components/FileSelectBox/FileSelectBox.test.tsx index 40e96d6..f83448e 100644 --- a/categories/web/blob/react/src/components/FileSelectBox/FileSelectBox.test.tsx +++ b/categories/web/blob/react/src/components/FileSelectBox/FileSelectBox.test.tsx @@ -3,6 +3,7 @@ import { render, screen, cleanup, + fireEvent, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -16,10 +17,48 @@ import matchers from '@testing-library/jest-dom/matchers'; import { FileSelectBox, FileSelectBoxDerivedElement, + DELETE_KEYS, SELECT_KEYS, } from '.'; expect.extend(matchers); +const userEventDrop = async ( + dropZoneElement: HTMLElement, + fileOrFiles: File | File[], + targetInputElement: HTMLInputElement, +) => { + const dummyInput = window.document.createElement('input'); + dummyInput.type = 'file'; + await userEvent.upload(dummyInput, fileOrFiles); + + // targeting change event on the input element + await userEvent.upload(targetInputElement, fileOrFiles); + + fireEvent.drop(dropZoneElement, { + dataTransfer: { + files: dummyInput.files, + }, + }); +}; + +const userEventPaste = async ( + dropZoneElement: HTMLElement, + fileOrFiles: File | File[], +) => { + const dummyInput = window.document.createElement('input'); + dummyInput.type = 'file'; + await userEvent.upload(dummyInput, fileOrFiles); + + // targeting change event on the input element + await userEvent.upload(dropZoneElement, fileOrFiles); + + fireEvent.paste(dropZoneElement, { + clipboardData: { + files: dummyInput.files, + }, + }); +}; + describe('FileSelectBox', () => { afterEach(() => { cleanup(); @@ -35,42 +74,82 @@ describe('FileSelectBox', () => { }); it('renders a border', () => { - render( - , - ); - const border = screen.getByTestId('border'); - expect(border).toBeInTheDocument(); - }); - - it('renders a label', () => { - render( - , - ); - const textbox = screen.getByLabelText('foo'); - expect(textbox).toBeInTheDocument(); - const label = screen.getByTestId('label'); - expect(label).toHaveTextContent('foo'); - }); - - it('renders a hidden label', () => { - render( - , - ); - const textbox = screen.getByLabelText('foo'); - expect(textbox).toBeInTheDocument(); - const label = screen.queryByTestId('label'); - expect(label).toBeInTheDocument(); - expect(label).toHaveClass('sr-only'); - }); - - describe('enhanced', () => { + render( + , + ); + const border = screen.getByTestId('border'); + expect(border).toBeInTheDocument(); + }); + + it('renders a block component', () => { + render( + , + ); + const root = screen.getByTestId('root'); + expect(root).toHaveClass('flex w-full'); + }); + + it('renders a label', () => { + render( + , + ); + const textbox = screen.getByLabelText('foo'); + expect(textbox).toBeInTheDocument(); + const label = screen.getByTestId('label'); + expect(label).toHaveTextContent('foo'); + }); + + it('renders a hidden label', () => { + render( + , + ); + const textbox = screen.getByLabelText('foo'); + expect(textbox).toBeInTheDocument(); + const label = screen.queryByTestId('label'); + expect(label).toBeInTheDocument(); + expect(label).toHaveClass('sr-only'); + }); + + it('handles a change event', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + expect(onChange).toBeCalledTimes(1); + }); + + it('handles a blur event', async () => { + const onBlur = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + input.focus(); + input.blur(); + expect(onBlur).toBeCalledTimes(1); + }); + + describe('when enhanced', () => { it('renders a hint', () => { render( { hint="foo" />, ); + const hint = screen.getByTestId('hint'); expect(hint).toBeInTheDocument(); }); + + it('renders a preview for a single file', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + expect(input.files).toHaveLength(1); + const preview = screen.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + + it('renders a preview for a single file without name', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new Blob(['foo'], {type: 'text/plain'}); + await userEvent.upload(input, file as unknown as File); + expect(input.files).toHaveLength(1); + const preview = screen.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + + it('renders a preview for a single file without name', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const { size: _size, ...partialFile } = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, partialFile as unknown as File); + expect(input.files).toHaveLength(1); + const preview = screen.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + + it('renders a preview for multiple files', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const files = [ + new File(['foo'], 'foo.txt', {type: 'text/plain'}), + new File(['bar'], 'bar.txt', {type: 'text/plain'}), + ]; + await userEvent.upload(input, files); + expect(input.files).toHaveLength(files.length); + const preview = screen.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + + it('renders a preview for multiple files without names', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const files = [ + new Blob(['foo'], {type: 'text/plain'}), + new Blob(['bar'], {type: 'text/plain'}), + ]; + await userEvent.upload(input, files as unknown as File[]); + expect(input.files).toHaveLength(files.length); + const preview = screen.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + + it('renders actions when at least one file is selected', async () => { + render( + , + ); + + const input = screen.getByTestId('input'); + const files = [ + new File(['foo'], 'foo.txt', {type: 'text/plain'}), + new File(['bar'], 'bar.txt', {type: 'text/plain'}), + ]; + await userEvent.upload(input, files); + const actions = screen.getByTestId('actions'); + expect(actions).toBeInTheDocument(); + }); + + it('clears selected files through the clear button', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + + const clearButton = screen.getByTestId('clear'); + await userEvent.click(clearButton); + + expect(input.files).toHaveLength(0); + }); + + it.each(DELETE_KEYS)('clears selected files through pressing %s key', async (key) => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + input.focus(); + await userEvent.keyboard(`{${key}}`); + expect(input.files).toHaveLength(0); + }); + + it('ignores other key presses when input is focused', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + input.focus(); + await userEvent.keyboard('a'); + expect(input.files).toHaveLength(1); + }); + + it.each(SELECT_KEYS)('opens picker on pressing %s when input is in focus', async (key) => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + input.focus(); + await userEvent.keyboard(`{${key}}`); + // how to assert? + }); + + it('opens picker when files are previously selected (showPicker)', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const showPicker = vi.fn(); + input.showPicker = showPicker; + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + + const clearButton = screen.getByTestId('reselect'); + await userEvent.click(clearButton); + + expect(showPicker).toBeCalledTimes(1); + }); + + it('opens picker when files are previously selected (no showPicker)', async () => { + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const showPicker = vi.spyOn(input, 'click'); + const file = new File(['foo'], 'foo.txt', {type: 'text/plain'}); + await userEvent.upload(input, file); + + const clearButton = screen.getByTestId('reselect'); + await userEvent.click(clearButton); + + expect(showPicker).toBeCalledTimes(1); + }); + + it('accepts drop files', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const root = screen.getByTestId('root'); + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + + await userEventDrop(root, file, input); + expect(onChange).toBeCalledTimes(1); + }); + + it('accepts pasted files', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('input') as FileSelectBoxDerivedElement; + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + + await userEventPaste(input, file); + expect(onChange).toBeCalledTimes(1); + }); }); }); diff --git a/categories/web/blob/react/src/components/FileSelectBox/index.tsx b/categories/web/blob/react/src/components/FileSelectBox/index.tsx index 9ebd6be..e55b031 100644 --- a/categories/web/blob/react/src/components/FileSelectBox/index.tsx +++ b/categories/web/blob/react/src/components/FileSelectBox/index.tsx @@ -79,12 +79,14 @@ export interface FileSelectBoxProps< clearLabel?: string, } +export type FileSelectBoxDefaultPreviewComponentDerivedElement = HTMLDivElement; + /** * Default component for the {@link FileSelectBoxProps.previewComponent|previewComponent prop} * of the {@link FileSelectBox} component. */ export const FileSelectBoxDefaultPreviewComponent = React.forwardRef< - HTMLDivElement, + FileSelectBoxDefaultPreviewComponentDerivedElement, CommonPreviewComponentProps >(({ file, @@ -101,40 +103,40 @@ export const FileSelectBoxDefaultPreviewComponent = React.forwardRef< >
{file?.name ?? ( - - File - + + File + )}
{!mini && ( - <> - {typeof file?.type === 'string' && ( -
{file?.type}
- )} - {typeof file?.size === 'number' && ( -
- {new Intl.NumberFormat(undefined, { - style: 'unit', - unit: 'kilobyte', - unitDisplay: 'long', - }).format(file.size ?? 0)} -
- )} - {typeof file?.lastModified === 'number' && ( -
- -
- )} - + <> + {typeof file?.type === 'string' && ( +
{file?.type}
+ )} + {typeof file?.size === 'number' && ( +
+ {new Intl.NumberFormat(undefined, { + style: 'unit', + unit: 'kilobyte', + unitDisplay: 'long', + }).format(file.size)} +
+ )} + {typeof file?.lastModified === 'number' && ( +
+ +
+ )} + )} )); @@ -149,6 +151,16 @@ const isMouseUpEvent = (e: React.SyntheticEvent): e is React.MouseEvent => e.typ const isDropEvent = (e: React.SyntheticEvent): e is React.DragEvent => e.type === 'drop'; +const isPasteEvent = (e: React.SyntheticEvent): e is React.ClipboardEvent => e.type === 'paste'; + +export const DELETE_KEYS = ['Backspace', 'Delete'] as const; + +type DeleteKey = typeof DELETE_KEYS[number]; + +export const SELECT_KEYS = ['Enter', 'Space', 'Return'] as const; + +type SelectKey = typeof SELECT_KEYS[number]; + /** * Component for selecting files. */ @@ -171,6 +183,7 @@ export const FileSelectBox = React.forwardRef(); const [lastUpdated, setLastUpdated] = React.useState(); const clearFileListRef = React.useRef(false); - const [deleteKeyPressed, setDeleteKeyPressed] = React.useState(false); + const [aboutToClear, setAboutToClear] = React.useState(false); const [aboutToSelect, setAboutToSelect] = React.useState(false); const clearFiles = ( fileInput: FileSelectBoxDerivedElement, clearRef: React.MutableRefObject, ) => { - const clearRefMut = clearRef; + const clearRefMut = clearRef as unknown as Record; clearRefMut.current = true; - const fileInputMut = fileInput; + const fileInputMut = fileInput as unknown as Record; fileInputMut.value = ''; setFileList(undefined); @@ -200,28 +213,26 @@ export const FileSelectBox = React.forwardRef | React.MouseEvent | React.DragEvent - | React.KeyboardEvent, + | React.KeyboardEvent + | React.ClipboardEvent, FileSelectBoxDerivedElement >({ forwardedRef, valueSetterFn: (e) => { - const fileInput = defaultRef.current; - if (!fileInput) { - return; - } - if (isButtonElement(e.currentTarget) && isMouseUpEvent(e)) { + const fileInput = defaultRef.current as FileSelectBoxDerivedElement; + if (isMouseUpEvent(e) && isButtonElement(e.currentTarget)) { clearFiles(fileInput, clearFileListRef); return; } - if (isInputElement(e.currentTarget) && isKeyUpEvent(e)) { + if (isKeyUpEvent(e) && isInputElement(e.currentTarget) && e.currentTarget === fileInput) { // delete via keyboard const { code } = e; - if (!(code === 'Backspace' || code === 'Delete')) { - return; + if (DELETE_KEYS.includes(code as DeleteKey)) { + clearFiles(fileInput, clearFileListRef); + setAboutToClear(false); } - clearFiles(e.currentTarget, clearFileListRef); return; } if (isDropEvent(e)) { @@ -229,12 +240,31 @@ export const FileSelectBox = React.forwardRef 0) { - setFileList(fileInput.files = files); + try { + fileInput.files = files; + } catch { + // noop, the assignment throws for test environments + } + setFileList(files); setLastUpdated(Date.now()); } return; } - console.warn('Unhandled event', e); + if (isPasteEvent(e)) { + const { clipboardData } = e; + const { files } = clipboardData; + if (files && files.length > 0) { + try { + fileInput.files = files; + } catch { + // noop, the assignment throws for test environments + } + setFileList(files); + setLastUpdated(Date.now()); + } + return; + } + // Unhandled event }, }); const labelId = React.useId(); @@ -254,45 +284,32 @@ export const FileSelectBox = React.forwardRef = React.useCallback((e) => { const { currentTarget } = e; - if (clientSide && currentTarget.files && currentTarget.files.length > 0) { + if (currentTarget.files && currentTarget.files.length > 0) { setFileList(currentTarget.files); setLastUpdated(Date.now()); setAboutToSelect(false); - onChange?.(e); - return; - } - if (clientSide && clearFileListRef.current) { + } else if (clearFileListRef.current) { clearFileListRef.current = false; setFileList(undefined); setLastUpdated(Date.now()); - onChange?.(e); - return; } - e.preventDefault(); - e.currentTarget.files = fileList ?? null; - setAboutToSelect(false); - }, [clientSide, fileList, onChange]); + onChange?.(e); + }, [clearFileListRef, onChange]); const handleKeyDown: React.KeyboardEventHandler< FileSelectBoxDerivedElement > = React.useCallback((e) => { const { code } = e; - if (code === 'Backspace' || code === 'Delete') { - setDeleteKeyPressed(true); - } else if (code === 'Enter' || code === 'Space' || code === 'Return') { - setAboutToSelect(true); + if (DELETE_KEYS.includes(code as DeleteKey)) { + setAboutToClear(true); + return; } - }, []); - const handleKeyUp: React.KeyboardEventHandler< - FileSelectBoxDerivedElement - > = React.useCallback((e) => { - const { code } = e; - if (code === 'Backspace' || code === 'Delete') { - doSetFileList(e); - setDeleteKeyPressed(false); + if (SELECT_KEYS.includes(code as SelectKey)) { + setAboutToSelect(true); } - }, [doSetFileList]); + // ignore other keys + }, []); const handleReselectMouseDown: React.MouseEventHandler< HTMLButtonElement @@ -308,13 +325,17 @@ export const FileSelectBox = React.forwardRef = React.useCallback(() => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; + if (typeof fileInput.showPicker !== 'function') { + fileInput.click(); + return; + } fileInput.showPicker(); }, [defaultRef]); const handleDeleteMouseDown: React.MouseEventHandler< HTMLButtonElement > = React.useCallback(() => { - setDeleteKeyPressed(true); + setAboutToClear(true); setTimeout(() => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; fileInput.focus(); @@ -325,14 +346,14 @@ export const FileSelectBox = React.forwardRef = React.useCallback((e) => { setAboutToSelect(false); - setDeleteKeyPressed(false); + setAboutToClear(false); onBlur?.(e); }, [onBlur]); React.useEffect(() => { const handleLabelMouseUp = () => { setAboutToSelect(false); - setDeleteKeyPressed(false); + setAboutToClear(false); }; window.addEventListener('mouseup', handleLabelMouseUp, { capture: true }); @@ -342,6 +363,7 @@ export const FileSelectBox = React.forwardRef 0 && clientSide && ( - -
+ +
{multiple && (
- {Array.from(fileList ?? []).map((file) => ( + {fileListArray.map((file, i) => (
@@ -458,9 +486,9 @@ export const FileSelectBox = React.forwardRef )} {!multiple - && Array.from(fileList ?? []).map((file) => ( + && fileListArray.map((file, i) => (
))}
-
+