From 52a1c65fe4ed0503c79127453c69e4c0f1c974eb Mon Sep 17 00:00:00 2001 From: Ian Flanigan Date: Sat, 28 May 2022 19:52:48 +0200 Subject: [PATCH] Create a FileChooser component using showOpenFilePicker (#116) * Create a FileChooser component using showOpenFilePicker Before, `FileModal` always used a file input control for selecting local files. This allowed the emulator to read from the file, but precluded writing back to the file. With this change, the `FileModal` delegates to the new `FileChooser` component. The `FileChooser` will use `showOpenFilePicker` if it is available and a regular file input if it's not. Using `showOpenFilePicker` has the advantage of allowing the emulator to write back to the file (if the user grants permission). While the emulator does not yet take advantage of this write capability, that will come. * Addressed comments * useState() instead of direct DOM manipulation * backed out eslint changes in favor of suppressing the warning --- jest.config.js | 5 +- js/components/FileChooser.tsx | 210 +++++++++++++++++++ js/components/FileModal.tsx | 47 +++-- package-lock.json | 292 +++++++++++++++++++++++++-- package.json | 2 + test/components/FileChooser.spec.tsx | 129 ++++++++++++ test/env/jsdom-with-backdoors.d.ts | 10 + test/env/jsdom-with-backdoors.js | 34 ++++ tsconfig.json | 68 +++---- 9 files changed, 726 insertions(+), 71 deletions(-) create mode 100644 js/components/FileChooser.tsx create mode 100644 test/components/FileChooser.spec.tsx create mode 100644 test/env/jsdom-with-backdoors.d.ts create mode 100644 test/env/jsdom-with-backdoors.js diff --git a/jest.config.js b/jest.config.js index 7df423e..a89c3bf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,12 +8,13 @@ module.exports = { 'test/', ], 'testMatch': [ - '**/?(*.)+(spec|test).+(ts|js)' + '**/?(*.)+(spec|test).+(ts|js|tsx)' ], 'transform': { '^.+\\.js$': 'babel-jest', - '^.+\\.ts$': 'ts-jest' + '^.+\\.ts$': 'ts-jest', + '^.*\\.tsx$': 'ts-jest', }, 'setupFilesAfterEnv': [ '/test/jest-setup.js' diff --git a/js/components/FileChooser.tsx b/js/components/FileChooser.tsx new file mode 100644 index 0000000..252f54a --- /dev/null +++ b/js/components/FileChooser.tsx @@ -0,0 +1,210 @@ +import { h, Fragment } from 'preact'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; + +export interface FilePickerAcceptType { + description?: string | undefined; + accept: Record; +} + +const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = { + description: 'Any file', + accept: { '*/*': [] }, +}; + +export interface FileSystemFileHandleLike { + readonly name: string; + readonly kind: string; + readonly isWritable: boolean; + getFile(): Promise; + createWritable: FileSystemFileHandle['createWritable']; +} + +export interface FileChooserProps { + disabled?: boolean; + onChange: (handles: Array) => void; + accept?: FilePickerAcceptType[]; + control?: typeof controlDefault; +} + +const hasPicker: boolean = !!window.showOpenFilePicker; +const controlDefault = hasPicker ? 'picker' : 'input'; + +interface InputFileChooserProps { + disabled?: boolean; + onChange?: (files: FileList) => void; + accept?: FilePickerAcceptType[]; +} + +interface ExtraProps { + accept?: string; +} + +const InputFileChooser = ({ + disabled = false, + onChange = () => { }, + accept = [], +}: InputFileChooserProps) => { + const inputRef = useRef(null); + const filesRef = useRef(); + + const onChangeInternal = useCallback(() => { + if (inputRef.current?.files) { + const newFiles = inputRef.current?.files; + if (filesRef.current !== newFiles) { + filesRef.current = newFiles; + onChange(newFiles); + } + } + }, []); + + const extraProps = useMemo(() => { + // Accept all of the given MIME types and extensions. An argument + // could be made to throw out all of the MIME types and just keep + // the extensions, but this seemed to follow the intent in the + // spec: + // + // https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file) + // + // which allows pretty generous interpretations. + // + const newAccept = []; + for (const type of accept) { + for (const [typeString, suffixes] of Object.entries(type.accept)) { + newAccept.push(typeString); + if (Array.isArray(suffixes)) { + newAccept.push(...suffixes); + } else { + newAccept.push(suffixes); + } + } + } + + const extraProps: { accept?: string } = {}; + if (newAccept.length > 0) { + extraProps['accept'] = newAccept.join(','); + } + + return extraProps; + }, [accept]); + + return ( + + ); +}; + +interface FilePickerChooserProps { + disabled?: boolean; + onChange?: (files: FileSystemFileHandle[]) => void; + accept?: FilePickerAcceptType[]; +} + +const FilePickerChooser = ({ + disabled = false, + onChange = () => { }, + accept = [ACCEPT_EVERYTHING_TYPE] +}: FilePickerChooserProps) => { + const [busy, setBusy] = useState(false); + const [selectedFilename, setSelectedFilename] = useState(); + const fileHandlesRef = useRef(); + + const onClickInternal = useCallback(async () => { + if (busy) { + return; + } + setBusy(true); + try { + const pickedFiles = await window.showOpenFilePicker({ + multiple: false, + excludeAcceptAllOption: true, + types: accept, + }); + if (fileHandlesRef.current !== pickedFiles) { + fileHandlesRef.current = pickedFiles; + onChange(pickedFiles); + } + } catch (e: unknown) { + console.error(e); + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + setSelectedFilename( + fileHandlesRef.current?.length + ? fileHandlesRef.current[0].name + : 'No file selected'); + }, [fileHandlesRef.current]); + + return ( + <> + +   + {selectedFilename} + + ); +}; + +/** + * File chooser component displayed as a button followed by the name of the + * chosen file (if any). When clicked, the button opens a native file chooser. + * If the browser supports the `window.showOpenFilePicker` function, this + * component uses it to open the file chooser. Otherwise, the component uses + * a regular file input element. + * + * Using `window.showOpenFilePicker` has the advantage of allowing read/write + * access to the file, whereas the regular input element only gives read + * access. + */ +export const FileChooser = ({ + onChange, + control = controlDefault, + ...rest +}: FileChooserProps) => { + + const onChangeForInput = useCallback((files: FileList) => { + const handles: FileSystemFileHandleLike[] = []; + for (let i = 0; i < files.length; i++) { + const file = files.item(i); + if (file === null) { + continue; + } + handles.push({ + kind: 'file', + name: file.name, + getFile: () => Promise.resolve(file), + isWritable: false, + createWritable: (_options) => Promise.reject('File not writable.'), + }); + } + onChange(handles); + }, []); + + const onChangeForPicker = useCallback((fileHandles: FileSystemFileHandle[]) => { + const handles: FileSystemFileHandleLike[] = []; + for (const fileHandle of fileHandles) { + handles.push({ + kind: fileHandle.kind, + name: fileHandle.name, + getFile: () => fileHandle.getFile(), + isWritable: true, + createWritable: (options) => fileHandle.createWritable(options), + }); + } + onChange(handles); + }, []); + + return control === 'picker' + ? ( + + ) + : ( + + ); +}; diff --git a/js/components/FileModal.tsx b/js/components/FileModal.tsx index 64a0ecb..5a82893 100644 --- a/js/components/FileModal.tsx +++ b/js/components/FileModal.tsx @@ -1,11 +1,19 @@ import { h, JSX } from 'preact'; -import { useCallback, useRef, useState } from 'preact/hooks'; -import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types'; +import { useCallback, useState } from 'preact/hooks'; +import { DiskDescriptor, DriveNumber, NibbleFormat, NIBBLE_FORMATS } from '../formats/types'; import { Modal, ModalContent, ModalFooter } from './Modal'; import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files'; import DiskII from '../cards/disk2'; import index from 'json/disks/index.json'; +import { FileChooser, FilePickerAcceptType, FileSystemFileHandleLike } from './FileChooser'; + +const DISK_TYPES: FilePickerAcceptType[] = [ + { + description: 'Disk Images', + accept: { 'application/octet-stream': NIBBLE_FORMATS.map(x => '.' + x) }, + } +]; const categories = index.reduce>( ( @@ -35,27 +43,29 @@ interface FileModalProps { onClose: (closeBox?: boolean) => void; } -export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) => { - const inputRef = useRef(null); +export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => { const [busy, setBusy] = useState(false); const [empty, setEmpty] = useState(true); const [category, setCategory] = useState(); + const [handles, setHandles] = useState(); const [filename, setFilename] = useState(); const doCancel = useCallback(() => onClose(true), []); - const doOpen = useCallback(() => { + const doOpen = useCallback(async () => { const hashParts = getHashParts(); - if (disk2 && inputRef.current && inputRef.current.files?.length === 1) { + if (disk2 && handles && handles.length === 1) { hashParts[number] = ''; setBusy(true); - loadLocalFile(disk2, number, inputRef.current.files[0]) - .catch(console.error) - .finally(() => { - setBusy(false); - onClose(); - }); + try { + await loadLocalFile(disk2, number, await handles[0].getFile()); + } catch (e) { + console.error(e); + } finally { + setBusy(false); + onClose(); + } } if (disk2 && filename) { @@ -71,13 +81,12 @@ export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) = } setHashParts(hashParts); - }, [ disk2, filename, number, onClose ]); + }, [disk2, filename, number, onClose, handles]); - const onChange = useCallback(() => { - if (inputRef) { - setEmpty(!inputRef.current?.files?.length); - } - }, [ inputRef ]); + const onChange = useCallback((handles: FileSystemFileHandleLike[]) => { + setEmpty(handles.length === 0); + setHandles(handles); + }, []); const doSelectCategory = useCallback( (event: JSX.TargetedMouseEvent) => @@ -112,7 +121,7 @@ export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) = ))} - + diff --git a/package-lock.json b/package-lock.json index 93cc95e..357a41f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,12 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", "@types/jest-image-snapshot": "^4.3.1", "@types/micromodal": "^0.3.2", + "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "ajv": "^6.12.0", @@ -2901,6 +2903,145 @@ "node": ">=8" } }, + "node_modules/@testing-library/preact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", + "integrity": "sha512-RHjln1psbU4Sh/l8k9/gG3VNEDIEicUhzZ74uEnb4hJ4H9G9p1iOXEEMXB2oD5sZcjciQhpx1QUryM5/sAtTTQ==", + "dev": true, + "dependencies": { + "@testing-library/dom": "^8.11.1" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" + } + }, + "node_modules/@testing-library/preact/node_modules/@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/preact/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@testing-library/preact/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/preact/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/preact/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/preact/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/preact/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/user-event": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.1.3.tgz", @@ -3336,6 +3477,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/wicg-file-system-access": { + "version": "2020.9.5", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", + "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -4531,14 +4678,20 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001283", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001283.tgz", - "integrity": "sha512-9RoKo841j1GQFSJz/nCXOj0sD7tHBtlowjYlrqIUS812x9/emfBLBt6IyMz1zIaYc/eRL8Cs6HPUVi2Hzq4sIg==", + "version": "1.0.30001342", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001342.tgz", + "integrity": "sha512-bn6sOCu7L7jcbBbyNhLg0qzXdJ/PMbybZTH/BA6Roet9wxYRm6Tr9D0s0uhLkOZ6MSG+QU6txUgdpr3MXIVqjA==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] }, "node_modules/canvas": { "version": "2.8.0", @@ -5188,9 +5341,9 @@ } }, "node_modules/dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz", + "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, "node_modules/domexception": { @@ -15809,6 +15962,107 @@ } } }, + "@testing-library/preact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", + "integrity": "sha512-RHjln1psbU4Sh/l8k9/gG3VNEDIEicUhzZ74uEnb4hJ4H9G9p1iOXEEMXB2oD5sZcjciQhpx1QUryM5/sAtTTQ==", + "dev": true, + "requires": { + "@testing-library/dom": "^8.11.1" + }, + "dependencies": { + "@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "@testing-library/user-event": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.1.3.tgz", @@ -16205,6 +16459,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/wicg-file-system-access": { + "version": "2020.9.5", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", + "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", + "dev": true + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -17130,9 +17390,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001283", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001283.tgz", - "integrity": "sha512-9RoKo841j1GQFSJz/nCXOj0sD7tHBtlowjYlrqIUS812x9/emfBLBt6IyMz1zIaYc/eRL8Cs6HPUVi2Hzq4sIg==", + "version": "1.0.30001342", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001342.tgz", + "integrity": "sha512-bn6sOCu7L7jcbBbyNhLg0qzXdJ/PMbybZTH/BA6Roet9wxYRm6Tr9D0s0uhLkOZ6MSG+QU6txUgdpr3MXIVqjA==", "dev": true }, "canvas": { @@ -17651,9 +17911,9 @@ } }, "dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz", + "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, "domexception": { diff --git a/package.json b/package.json index 84970fd..9bfae0d 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,12 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", "@types/jest-image-snapshot": "^4.3.1", "@types/micromodal": "^0.3.2", + "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "ajv": "^6.12.0", diff --git a/test/components/FileChooser.spec.tsx b/test/components/FileChooser.spec.tsx new file mode 100644 index 0000000..210c061 --- /dev/null +++ b/test/components/FileChooser.spec.tsx @@ -0,0 +1,129 @@ +/** + * @jest-environment ./test/env/jsdom-with-backdoors + */ + +import 'jest'; +import { h } from 'preact'; +import { fireEvent, render, screen, waitFor } from '@testing-library/preact'; + +import 'test/env/jsdom-with-backdoors'; +import { FileChooser, FileChooserProps } from '../../js/components/FileChooser'; + +type ShowOpenFilePicker = typeof window.showOpenFilePicker; + +const FAKE_FILE_HANDLE = { + kind: 'file', + name: 'file name', + createWritable: jest.fn(), + getFile: jest.fn(), + isSameEntry: jest.fn(), + queryPermission: jest.fn(), + requestPermission: jest.fn(), + isFile: true, + isDirectory: false, +} as const; + +// eslint-disable-next-line no-undef +const EMPTY_FILE_LIST = backdoors.newFileList(); + +const FAKE_FILE = new File([], 'fake'); + +describe('FileChooser', () => { + describe('input-based chooser', () => { + it('should be instantiable', () => { + const { container } = render( { }} />); + + expect(container).not.toBeNull(); + }); + + it('should use the file input element', async () => { + render( { }} />); + + const inputElement = await screen.findByRole('button') as HTMLInputElement; + expect(inputElement.type).toBe('file'); + }); + + it('should fire a callback with empty list when no files are selected', async () => { + const onChange = jest.fn(); + render(); + const inputElement = await screen.findByRole('button') as HTMLInputElement; + inputElement.files = EMPTY_FILE_LIST; + fireEvent.change(inputElement); + await waitFor(() => { + expect(onChange).toBeCalledWith([]); + }); + }); + + it('should fire a callback with a file handle when a file is selected', async () => { + const onChange = jest.fn, Parameters>(); + render(); + const inputElement = await screen.findByRole('button') as HTMLInputElement; + // eslint-disable-next-line no-undef + inputElement.files = backdoors.newFileList(FAKE_FILE); + fireEvent.change(inputElement); + await waitFor(async () => { + expect(onChange).toHaveBeenCalled(); + const handleList = onChange.mock.calls[0][0]; + expect(handleList).toHaveLength(1); + const handle = handleList[0]; + expect(handle.kind).toBe('file'); + expect(handle.name).toBe(FAKE_FILE.name); + expect(handle.isWritable).toBe(false); + await expect(handle.getFile()).resolves.toBe(FAKE_FILE); + await expect(handle.createWritable()).rejects.toEqual('File not writable.'); + }); + }); + }); + + describe('picker-base chooser', () => { + const mockFilePicker = jest.fn, Parameters>(); + + beforeEach(() => { + expect(window.showOpenFilePicker).not.toBeDefined(); + window.showOpenFilePicker = mockFilePicker as unknown as ShowOpenFilePicker; + mockFilePicker.mockReset(); + }); + + afterEach(() => { + window.showOpenFilePicker = undefined as unknown as typeof window.showOpenFilePicker; + }); + + it('should be instantiable', () => { + const { container } = render( { }} />); + + expect(container).not.toBeNull(); + }); + + it('should fire a callback with empty list when no files are selected', async () => { + mockFilePicker.mockResolvedValueOnce([]); + const onChange = jest.fn(); + render(); + + fireEvent.click(await screen.findByText('Choose File')); + + await waitFor(() => { + expect(mockFilePicker).toBeCalled(); + expect(onChange).toBeCalledWith([]); + }); + }); + + it('should fire a callback with a file handle when a file is selected', async () => { + mockFilePicker.mockResolvedValueOnce([FAKE_FILE_HANDLE]); + const onChange = jest.fn, Parameters>(); + render(); + + fireEvent.click(await screen.findByText('Choose File')); + + await waitFor(() => { + expect(mockFilePicker).toBeCalled(); + expect(onChange).toHaveBeenCalled(); + const handleList = onChange.mock.calls[0][0]; + expect(handleList).toHaveLength(1); + const handle = handleList[0]; + expect(handle.kind).toBe(FAKE_FILE_HANDLE.kind); + expect(handle.name).toBe(FAKE_FILE_HANDLE.name); + expect(handle.isWritable).toBe(true); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/env/jsdom-with-backdoors.d.ts b/test/env/jsdom-with-backdoors.d.ts new file mode 100644 index 0000000..b6bfab4 --- /dev/null +++ b/test/env/jsdom-with-backdoors.d.ts @@ -0,0 +1,10 @@ +/** + * Provide types for the jsdom-with-backdoors testing environment. + */ +export { }; + +declare global { + const backdoors: { + newFileList(...files: File[]): FileList; + }; +} diff --git a/test/env/jsdom-with-backdoors.js b/test/env/jsdom-with-backdoors.js new file mode 100644 index 0000000..f50d323 --- /dev/null +++ b/test/env/jsdom-with-backdoors.js @@ -0,0 +1,34 @@ +/** + * This is a total and terrible hack that allows us to create otherwise + * uninstantiable jsdom objects. Currently this exposes a way to create + * `FileList` objects. + * + * This was inspired by felipochoa's implementation in GitHub issue: + * https://github.com/jsdom/jsdom/issues/1272. This implementation is + * "better" because it does all of the dirty work during environment + * setup. It still requires typing. + */ + +const JsdomEnvironment = require('jest-environment-jsdom'); + +export default class JsdomEnvironmentWithBackDoors extends JsdomEnvironment { + async setup() { + await super.setup(); + const jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils'); + const jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList'); + + this.global.backdoors = { + newFileList: (...files) => { + const impl = jsdomFileList.createImpl(this.global); + const fileList = Object.assign([...files], { + item: i => fileList[i], + [jsdomUtils.implSymbol]: impl, + }); + impl[jsdomUtils.wrapperSymbol] = fileList; + const fileListCtor = this.global[jsdomUtils.ctorRegistrySymbol].FileList; + Object.setPrototypeOf(fileList, fileListCtor.prototype); + return fileList; + }, + }; + } +} diff --git a/tsconfig.json b/tsconfig.json index 83e97b2..403784a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,40 +1,40 @@ { "compilerOptions": { - "jsx": "react", - "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "module": "esnext", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "target": "es6", - "lib": ["DOM", "ES6"], - "noImplicitAny": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "sourceMap": true, - "strictNullChecks": true, - "outDir": "dist", - "baseUrl": ".", - "allowJs": true, - "paths": { - "*": [ - "node_modules/*", - "types/*" - ], - "js/*": [ - "js/*" - ], - "json/*": [ - "json/*" - ], + "jsx": "react", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + "module": "esnext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "target": "es6", + "lib": ["DOM", "ES6"], + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true, + "strictNullChecks": true, + "outDir": "dist", + "baseUrl": ".", + "allowJs": true, + "paths": { + "*": [ + "node_modules/*", + "types/*" + ], + "js/*": [ + "js/*" + ], + "json/*": [ + "json/*" + ], "test/*": [ - "test/*" - ] - } + "test/*" + ] + } }, "include": [ "js/**/*",