From 4f29ab017ba2de5eb76850957675c87194b72de3 Mon Sep 17 00:00:00 2001 From: MrKampla Date: Fri, 8 Aug 2025 19:01:21 +0200 Subject: [PATCH] feat: make openFilePicker function return a promise, add detection of cancellation --- README.md | 41 ++- packages/example/src/UseFilePicker.tsx | 13 +- .../example/src/UseImperativeFilePicker.tsx | 9 +- packages/use-file-picker/package.json | 2 +- .../src/helpers/openFileDialog.ts | 76 ++-- packages/use-file-picker/src/interfaces.ts | 43 ++- packages/use-file-picker/src/useFilePicker.ts | 232 +++++++----- .../src/useImperativeFilePicker.ts | 28 +- .../test/FilePickerTestComponents.tsx | 4 +- .../test/PromiseBasedPicker.test.tsx | 346 ++++++++++++++++++ packages/use-file-picker/test/testUtils.ts | 23 +- 11 files changed, 664 insertions(+), 153 deletions(-) create mode 100644 packages/use-file-picker/test/PromiseBasedPicker.test.tsx diff --git a/README.md b/README.md index 1e7abd5..e9b6368 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,19 @@ export default function App() { return (
- +
{filesContent.map((file, index) => (
@@ -344,19 +356,20 @@ const Imperative = () => { ### Props -| Prop name | Description | Default value | Example values | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------ | -| multiple | Allow user to pick multiple files at once | true | true, false | -| accept | Set type of files that user can choose from the list | "\*" | [".png", ".txt"], "image/\*", ".txt" | -| readAs | Set a return type of [filesContent](#returns) | "Text" | "DataURL", "Text", "BinaryString", "ArrayBuffer" | -| readFilesContent | Ignores files content and omits reading process if set to false | true | true, false | -| validators | Add validation logic. You can use some of the [built-in validators](#built-in-validators) like FileAmountLimitValidator or create your own [custom validation](#custom-validation) logic | [] | [MyValidator, MySecondValidator] | -| initializeWithCustomParameters | allows for customization of the input element that is created by the file picker. It accepts a function that takes in the input element as a parameter and can be used to set any desired attributes or styles on the element. | n/a | (input) => input.setAttribute("disabled", "") | -| encoding | Specifies the encoding to use when reading text files. Only applicable when readAs is set to "Text". Available options include all standard encodings. | "utf-8" | "latin1", "utf-8", "windows-1252" | -| onFilesSelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) | -| onFilesSuccessfullySelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) | -| onFilesRejected | A callback function that is called when files are rejected due to validation errors or other issues. The function is passed an array of objects with information about each rejected file | n/a | (data) => console.log(data) | -| onClear | A callback function that is called when the selection is cleared. | n/a | () => console.log('selection cleared') | +| Prop name | Description | Default value | Example values | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------- | +| multiple | Allow user to pick multiple files at once | true | true, false | +| accept | Set type of files that user can choose from the list | "\*" | [".png", ".txt"], "image/\*", ".txt" | +| readAs | Set a return type of [filesContent](#returns) | "Text" | "DataURL", "Text", "BinaryString", "ArrayBuffer" | +| readFilesContent | Ignores files content and omits reading process if set to false | true | true, false | +| validators | Add validation logic. You can use some of the [built-in validators](#built-in-validators) like FileAmountLimitValidator or create your own [custom validation](#custom-validation) logic | [] | [MyValidator, MySecondValidator] | +| initializeWithCustomParameters | allows for customization of the input element that is created by the file picker. It accepts a function that takes in the input element as a parameter and can be used to set any desired attributes or styles on the element. | n/a | (input) => input.setAttribute("disabled", "") | +| encoding | Specifies the encoding to use when reading text files. Only applicable when readAs is set to "Text". Available options include all standard encodings. | "utf-8" | "latin1", "utf-8", "windows-1252" | +| onFilesSelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) | +| onFilesSuccessfullySelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) | +| onFilesRejected | A callback function that is called when files are rejected due to validation errors or other issues. The function is passed an array of objects with information about each rejected file | n/a | (data) => console.log(data) | +| onClear | A callback function that is called when the selection is cleared. | n/a | () => console.log('selection cleared') | +| detectCancellation | Whether to return an error when user cancels the file picker. Has two possible modes: "promiseResultOnly" or "promiseResultAndHookState". When set to "promiseResultOnly", the error will be returned as part of the errors array returned from the openFilePicker async function. When set to "promiseResultAndHookState", the error will also be appended to the errors array returned from the hook call | false | "promiseResultOnly", "promiseResultAndHookState", false | ### Returns diff --git a/packages/example/src/UseFilePicker.tsx b/packages/example/src/UseFilePicker.tsx index 990031e..a509c39 100644 --- a/packages/example/src/UseFilePicker.tsx +++ b/packages/example/src/UseFilePicker.tsx @@ -39,10 +39,8 @@ export const UseFilePicker = () => { readAs: 'Text', // availible formats: "Text" | "BinaryString" | "ArrayBuffer" | "DataURL" initializeWithCustomParameters(inputElement) { inputElement.webkitdirectory = selectionMode === 'dir'; - inputElement.addEventListener('cancel', () => { - alert('cancel'); - }); }, + detectCancellation: 'promiseResultOnly', // accept: ['.png', '.jpeg', '.heic'], // readFilesContent: false, // ignores file content, validators: [ @@ -73,7 +71,14 @@ export const UseFilePicker = () => { - +
Amount of selected files: diff --git a/packages/example/src/UseImperativeFilePicker.tsx b/packages/example/src/UseImperativeFilePicker.tsx index 92d8475..b99e1e9 100644 --- a/packages/example/src/UseImperativeFilePicker.tsx +++ b/packages/example/src/UseImperativeFilePicker.tsx @@ -42,7 +42,14 @@ export const UseImperativeFilePicker = () => { - +
Amount of selected files: diff --git a/packages/use-file-picker/package.json b/packages/use-file-picker/package.json index 069c7c1..c812155 100644 --- a/packages/use-file-picker/package.json +++ b/packages/use-file-picker/package.json @@ -1,7 +1,7 @@ { "name": "use-file-picker", "description": "Simple react hook to open browser file selector.", - "version": "2.1.4", + "version": "2.2.0", "license": "MIT", "author": "Milosz Jankiewicz, Kamil Planer", "homepage": "https://github.com/Jaaneek/useFilePicker", diff --git a/packages/use-file-picker/src/helpers/openFileDialog.ts b/packages/use-file-picker/src/helpers/openFileDialog.ts index bf19fe1..e89915d 100644 --- a/packages/use-file-picker/src/helpers/openFileDialog.ts +++ b/packages/use-file-picker/src/helpers/openFileDialog.ts @@ -1,41 +1,51 @@ -export function openFileDialog( +import type { SelectionCancelledError } from '../interfaces.js'; + +export async function openFileDialog( accept: string, multiple: boolean, callback: (arg: Event) => void, initializeWithCustomAttributes?: (arg: HTMLInputElement) => void -): void { - // this function must be called from a user - // activation event (ie an onclick event) +) { + return new Promise((resolve, reject) => { + // this function must be called from a user + // activation event (ie an onclick event) - // Create an input element - const inputElement = document.createElement('input'); - // Hide element and append to body (required to run on iOS safari) - inputElement.style.display = 'none'; - document.body.appendChild(inputElement); - // Set its type to file - inputElement.type = 'file'; - // Set accept to the file types you want the user to select. - // Include both the file extension and the mime type - // if accept is "*" then dont set the accept attribute - if (accept !== '*') inputElement.accept = accept; - // Accept multiple files - inputElement.multiple = multiple; - // set onchange event to call callback when user has selected file - //inputElement.addEventListener('change', callback); - inputElement.addEventListener('change', arg => { - callback(arg); - // remove element - document.body.removeChild(inputElement); - }); + // Create an input element + const inputElement = document.createElement('input'); + // Hide element and append to body (required to run on iOS safari) + inputElement.style.display = 'none'; + document.body.appendChild(inputElement); + // Set its type to file + inputElement.type = 'file'; + // Set accept to the file types you want the user to select. + // Include both the file extension and the mime type + // if accept is "*" then dont set the accept attribute + if (accept !== '*') inputElement.accept = accept; + // Accept multiple files + inputElement.multiple = multiple; + // set onchange event to call callback when user has selected file + //inputElement.addEventListener('change', callback); + inputElement.addEventListener('change', arg => { + callback(arg); + // remove element + document.body.removeChild(inputElement); + resolve(); + }); - inputElement.addEventListener('cancel', () => { - // remove element - document.body.removeChild(inputElement); - }); + inputElement.addEventListener('cancel', () => { + // remove element + document.body.removeChild(inputElement); + const error: SelectionCancelledError = { + name: 'SelectionCancelledError', + reason: 'SELECTION_CANCELLED', + }; + reject(error); + }); - if (initializeWithCustomAttributes) { - initializeWithCustomAttributes(inputElement); - } - // dispatch a click event to open the file dialog - inputElement.dispatchEvent(new MouseEvent('click')); + if (initializeWithCustomAttributes) { + initializeWithCustomAttributes(inputElement); + } + // dispatch a click event to open the file dialog + inputElement.dispatchEvent(new MouseEvent('click')); + }); } diff --git a/packages/use-file-picker/src/interfaces.ts b/packages/use-file-picker/src/interfaces.ts index 511c023..1a7a0b3 100644 --- a/packages/use-file-picker/src/interfaces.ts +++ b/packages/use-file-picker/src/interfaces.ts @@ -7,7 +7,13 @@ export type FileWithPath = FileWithPathFromSelector; // ========== ERRORS ========== -type BaseErrors = FileSizeError | FileReaderError | FileAmountLimitError | ImageDimensionError | FileTypeError; +type BaseErrors = + | FileSizeError + | FileReaderError + | FileAmountLimitError + | ImageDimensionError + | FileTypeError + | SelectionCancelledError; export type UseFilePickerError = CustomErrors extends object ? BaseErrors | CustomErrors @@ -48,6 +54,11 @@ export interface FileTypeError { reason: 'FILE_TYPE_NOT_ACCEPTED'; } +export interface SelectionCancelledError { + name: 'SelectionCancelledError'; + reason: 'SELECTION_CANCELLED'; +} + export type FileErrors = { errors: UseFilePickerError[]; }; @@ -113,6 +124,7 @@ type UseFilePickerConfigCommon = { onFilesRejected?: (data: FileErrors) => void; onClear?: () => void; initializeWithCustomParameters?: (inputElement: HTMLInputElement) => void; + detectCancellation?: 'promiseResultOnly' | 'promiseResultAndHookState' | false; }; type ReadFileContentConfig = | ({ @@ -160,8 +172,22 @@ export interface FileContent extends Blob { type: string; } +export type OpenFilePickerOverrideOptions = + | (Omit< + UseFilePickerConfigCommon & ReadFileContentConfig, + 'onFilesRejected' | 'onClear' | 'onFilesSelected' | 'onFilesSuccessfullySelected' + > & { readAs?: 'Text'; encoding?: Encoding }) + | (Omit< + UseFilePickerConfigCommon & ReadFileContentConfig, + 'onFilesRejected' | 'onClear' | 'onFilesSelected' | 'onFilesSuccessfullySelected' + > & { readAs?: Exclude; encoding?: never }); + export type FilePickerReturnTypes = { - openFilePicker: () => void; + openFilePicker: (options?: OpenFilePickerOverrideOptions) => Promise<{ + filesContent: FileContent[]; + errors: UseFilePickerError[]; + plainFiles: FileWithPath[]; + }>; filesContent: FileContent[]; errors: UseFilePickerError[]; loading: boolean; @@ -169,10 +195,17 @@ export type FilePickerReturnTypes = { clear: () => void; }; -export type ImperativeFilePickerReturnTypes = FilePickerReturnTypes< - ContentType, - CustomErrors +export type ImperativeFilePickerReturnTypes = Omit< + FilePickerReturnTypes, + 'openFilePicker' > & { + openFilePicker: (options?: OpenFilePickerOverrideOptions) => Promise<{ + filesContent: FileContent[]; + errors: UseFilePickerError[]; + plainFiles: FileWithPath[]; + addedFilesContent: FileContent[]; + addedPlainFiles: FileWithPath[]; + }>; removeFileByIndex: (index: number) => void; removeFileByReference: (file: File) => void; }; diff --git a/packages/use-file-picker/src/useFilePicker.ts b/packages/use-file-picker/src/useFilePicker.ts index e58a34b..d096954 100644 --- a/packages/use-file-picker/src/useFilePicker.ts +++ b/packages/use-file-picker/src/useFilePicker.ts @@ -7,6 +7,7 @@ import type { UseFilePickerError, ReaderMethod, ExtractContentTypeFromConfig, + OpenFilePickerOverrideOptions, } from './interfaces.js'; import { openFileDialog } from './helpers/openFileDialog.js'; import { useValidators } from './validators/useValidators.js'; @@ -26,6 +27,7 @@ function useFilePicker< readFilesContent = true, validators = EMPTY_ARRAY, initializeWithCustomParameters, + detectCancellation = false, } = props; const [plainFiles, setPlainFiles] = useState([]); @@ -49,17 +51,23 @@ function useFilePicker< }, [clear, onClear]); const parseFile = useCallback( - (file: FileWithPath) => + (file: FileWithPath, overrides: OpenFilePickerOverrideOptions = {}) => new Promise>>( ( resolve: (fileContent: FileContent>) => void, reject: (reason: UseFilePickerError) => void ) => { + const { + readAs: readAsOverride = readAs, + encoding: encodingOverride, + validators: validatorsOverride = validators, + } = overrides; const reader = new FileReader(); + const encoding = encodingOverride ?? (props.readAs === 'Text' ? props.encoding : undefined); //availible reader methods: readAsText, readAsBinaryString, readAsArrayBuffer, readAsDataURL - const readStrategy = reader[`readAs${readAs}` as ReaderMethod] as typeof reader.readAsText; - readStrategy.call(reader, file, props.readAs === 'Text' ? props.encoding : undefined); + const readStrategy = reader[`readAs${readAsOverride}` as ReaderMethod] as typeof reader.readAsText; + readStrategy.call(reader, file, encoding); const addError = ({ ...others }: UseFilePickerError) => { reject({ ...others }); @@ -67,7 +75,7 @@ function useFilePicker< reader.onload = async () => Promise.all( - validators.map(validator => + validatorsOverride.map(validator => validator .validateAfterParsing(props, file, reader) .catch((err: UseFilePickerError) => Promise.reject(addError(err))) @@ -93,91 +101,147 @@ function useFilePicker< [props, readAs, validators] ); - const openFilePicker = useCallback(() => { - const fileExtensions = accept instanceof Array ? accept.join(',') : accept; - openFileDialog( - fileExtensions, - multiple, - async evt => { - clear(); - setLoading(true); - const plainFileObjects = (await fromEvent(evt)) as FileWithPath[]; - - const validationsBeforeParsing = ( - await Promise.all( - validators.map(validator => - validator - .validateBeforeParsing(props, plainFileObjects) - .catch((err: UseFilePickerError | UseFilePickerError[]) => (Array.isArray(err) ? err : [err])) - ) - ) - ) - .flat(1) - .filter(Boolean) as UseFilePickerError[]; - - setPlainFiles(plainFileObjects); - setFileErrors(validationsBeforeParsing); - if (validationsBeforeParsing.length) { - setPlainFiles([]); - onFilesRejected?.({ errors: validationsBeforeParsing }); - onFilesSelected?.({ errors: validationsBeforeParsing }); - setLoading(false); - return; - } + const openFilePicker: FilePickerReturnTypes< + ExtractContentTypeFromConfig, + CustomErrors + >['openFilePicker'] = useCallback( + async (overrides = {}) => { + const { + accept: acceptOverride = accept, + initializeWithCustomParameters: initializeWithCustomParametersOverride = initializeWithCustomParameters, + multiple: multipleOverride = multiple, + readFilesContent: readFilesContentOverride = readFilesContent, + validators: validatorsOverride = validators, + detectCancellation: detectCancellationOverride = detectCancellation, + } = overrides; + return new Promise< + Awaited< + ReturnType, CustomErrors>['openFilePicker']> + > + >((resolve, reject) => { + const fileExtensions = acceptOverride instanceof Array ? acceptOverride.join(',') : acceptOverride; - if (!readFilesContent) { - onFilesSelected?.({ plainFiles: plainFileObjects, filesContent: [] }); - setLoading(false); - return; - } + openFileDialog( + fileExtensions, + multipleOverride, + async evt => { + clear(); + setLoading(true); + const plainFileObjects = (await fromEvent(evt)) as FileWithPath[]; - const validationsAfterParsing: UseFilePickerError[] = []; - const filesContent = (await Promise.all( - plainFileObjects.map(file => - parseFile(file).catch( - (fileError: UseFilePickerError | UseFilePickerError[]) => { - validationsAfterParsing.push(...(Array.isArray(fileError) ? fileError : [fileError])); - } + const validationsBeforeParsing = ( + await Promise.all( + validatorsOverride.map(validator => + validator + .validateBeforeParsing(props, plainFileObjects) + .catch((err: UseFilePickerError | UseFilePickerError[]) => (Array.isArray(err) ? err : [err])) + ) + ) ) - ) - )) as FileContent>[]; - setLoading(false); - - if (validationsAfterParsing.length) { - setPlainFiles([]); - setFilesContent([]); - setFileErrors(errors => [...errors, ...validationsAfterParsing]); - onFilesRejected?.({ errors: validationsAfterParsing }); - onFilesSelected?.({ - errors: validationsBeforeParsing.concat(validationsAfterParsing), - }); - return; - } + .flat(1) + .filter(Boolean) as UseFilePickerError[]; + + setPlainFiles(plainFileObjects); + setFileErrors(validationsBeforeParsing); + if (validationsBeforeParsing.length) { + setPlainFiles([]); + onFilesRejected?.({ errors: validationsBeforeParsing }); + onFilesSelected?.({ errors: validationsBeforeParsing }); + setLoading(false); + resolve({ + filesContent: [], + errors: validationsBeforeParsing, + plainFiles: plainFileObjects, + }); + return; + } - setFilesContent(filesContent); - setPlainFiles(plainFileObjects); - setFileErrors([]); - onFilesSuccessfullySelected?.({ filesContent: filesContent, plainFiles: plainFileObjects }); - onFilesSelected?.({ - plainFiles: plainFileObjects, - filesContent: filesContent, + if (!readFilesContentOverride) { + onFilesSelected?.({ plainFiles: plainFileObjects, filesContent: [] }); + setLoading(false); + resolve({ + filesContent: [], + errors: [], + plainFiles: plainFileObjects, + }); + return; + } + + const validationsAfterParsing: UseFilePickerError[] = []; + const filesContent = (await Promise.all( + plainFileObjects.map(file => + parseFile(file, overrides).catch( + (fileError: UseFilePickerError | UseFilePickerError[]) => { + validationsAfterParsing.push(...(Array.isArray(fileError) ? fileError : [fileError])); + } + ) + ) + )) as FileContent>[]; + setLoading(false); + + if (validationsAfterParsing.length) { + setPlainFiles([]); + setFilesContent([]); + setFileErrors(errors => [...errors, ...validationsAfterParsing]); + onFilesRejected?.({ errors: validationsAfterParsing }); + onFilesSelected?.({ + errors: validationsBeforeParsing.concat(validationsAfterParsing), + }); + resolve({ + filesContent: [], + errors: validationsAfterParsing, + plainFiles: plainFileObjects, + }); + return; + } + + setFilesContent(filesContent); + setPlainFiles(plainFileObjects); + setFileErrors([]); + onFilesSuccessfullySelected?.({ filesContent: filesContent, plainFiles: plainFileObjects }); + onFilesSelected?.({ + plainFiles: plainFileObjects, + filesContent: filesContent, + }); + resolve({ + filesContent: filesContent, + errors: [], + plainFiles: plainFileObjects, + }); + }, + initializeWithCustomParametersOverride ?? initializeWithCustomParameters + ).catch(err => { + if (err.name === 'SelectionCancelledError') { + if (detectCancellationOverride === 'promiseResultAndHookState') { + setFileErrors(errors => [...errors, err]); + } + resolve({ + filesContent: [], + errors: detectCancellationOverride ? [err] : [], + plainFiles: [], + }); + return; + } + + reject(err); }); - }, - initializeWithCustomParameters - ); - }, [ - props, - accept, - clear, - initializeWithCustomParameters, - multiple, - onFilesRejected, - onFilesSelected, - onFilesSuccessfullySelected, - parseFile, - readFilesContent, - validators, - ]); + }); + }, + [ + props, + accept, + clear, + initializeWithCustomParameters, + multiple, + onFilesRejected, + onFilesSelected, + onFilesSuccessfullySelected, + parseFile, + readFilesContent, + detectCancellation, + validators, + ] + ); return { openFilePicker, diff --git a/packages/use-file-picker/src/useImperativeFilePicker.ts b/packages/use-file-picker/src/useImperativeFilePicker.ts index 0fd6286..9fc2d08 100644 --- a/packages/use-file-picker/src/useImperativeFilePicker.ts +++ b/packages/use-file-picker/src/useImperativeFilePicker.ts @@ -84,8 +84,34 @@ function useImperativeFilePicker< [removeFileByIndex, allPlainFiles] ); + const openImperativeFilePicker: ImperativeFilePickerReturnTypes< + ExtractContentTypeFromConfig, + CustomErrors + >['openFilePicker'] = useCallback( + async (overrides = {}) => { + const result = await openFilePicker(overrides); + if (!result.errors.length) { + return { + plainFiles: [...allPlainFiles, ...result.plainFiles], + filesContent: [...allFilesContent, ...result.filesContent], + addedFilesContent: result.filesContent, + addedPlainFiles: result.plainFiles, + errors: [], + }; + } + return { + plainFiles: allPlainFiles, + filesContent: allFilesContent, + addedFilesContent: [], + addedPlainFiles: [], + errors: result.errors, + }; + }, + [openFilePicker] + ); + return { - openFilePicker, + openFilePicker: openImperativeFilePicker, plainFiles: allPlainFiles, filesContent: allFilesContent, loading, diff --git a/packages/use-file-picker/test/FilePickerTestComponents.tsx b/packages/use-file-picker/test/FilePickerTestComponents.tsx index f9d78e9..7e5b1db 100644 --- a/packages/use-file-picker/test/FilePickerTestComponents.tsx +++ b/packages/use-file-picker/test/FilePickerTestComponents.tsx @@ -52,7 +52,7 @@ export const FilePickerComponent = (props: UseFilePickerConfig) => {
) : null}
- + {filesContent?.map(file => (file ? renderDependingOnReaderType(file, props.readAs!) : null))} {plainFiles?.map(file => (
@@ -92,7 +92,7 @@ export const ImperativeFilePickerComponent = (props: UseFilePickerConfig) => {
) : null}
- + {filesContent?.map(file => (file ? renderDependingOnReaderType(file, props.readAs!) : null))} {plainFiles?.map(file => (
diff --git a/packages/use-file-picker/test/PromiseBasedPicker.test.tsx b/packages/use-file-picker/test/PromiseBasedPicker.test.tsx new file mode 100644 index 0000000..03a99dd --- /dev/null +++ b/packages/use-file-picker/test/PromiseBasedPicker.test.tsx @@ -0,0 +1,346 @@ +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { createFileOfSize, invokeUseFilePicker, invokeUseImperativeFilePicker } from './testUtils.js'; +import { userEvent } from '@testing-library/user-event'; +import { FileTypeValidator } from '../src/validators.js'; + +describe('PromisedBasedUseFilePicker', () => { + it('should return a promise from openFilePicker', async () => { + const user = userEvent.setup(); + const { input, result } = invokeUseFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker(); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + const files = [new File(['hello'], 'hello.png', { type: 'image/png' })]; + await user.upload(input.current!, files); + + await waitFor(() => result.current.loading === false); + + expect(result.current.plainFiles.length).toBe(1); + expect(input.current!.files).toHaveLength(1); + expect(input.current!.files?.[0]).toStrictEqual(files[0]); + promise.then(result => { + expect(result.plainFiles.length).toBe(1); + expect(input.current!.files).toHaveLength(1); + expect(input.current!.files?.[0]).toStrictEqual(files[0]); + }); + }); + + it('should also resolve the promise with empty arrays if the user cancels the selection', async () => { + const { input, result } = invokeUseFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker(); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(result => { + expect(result.plainFiles.length).toBe(0); + expect(result.filesContent.length).toBe(0); + expect(result.errors.length).toBe(0); + }); + }); + + it('should resolve the promise with the cancellation error if the user cancels the selection and detectCancellation is set to promiseResultOnly', async () => { + const { input, result } = invokeUseFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker({ detectCancellation: 'promiseResultOnly' }); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(result => { + expect(result.plainFiles.length).toBe(0); + expect(result.filesContent.length).toBe(0); + expect(result.errors.length).toBe(1); + expect(result.errors[0]?.name).toBe('SelectionCancelledError'); + if (result.errors[0]?.name === 'SelectionCancelledError') { + expect(result.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + await promise; + + // hook state doesn't show the error as user requested only the promise result to contain the error + expect(result.current.errors.length).toBe(0); + }); + + it('should resolve the promise with the cancellation error and return the error from the hook state if the user cancels the selection and detectCancellation is set to promiseResultAndHookState', async () => { + const { input, result } = invokeUseFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker({ detectCancellation: 'promiseResultAndHookState' }); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(promiseResult => { + expect(promiseResult.plainFiles.length).toBe(0); + expect(promiseResult.filesContent.length).toBe(0); + expect(promiseResult.errors.length).toBe(1); + expect(promiseResult.errors[0]?.name).toBe('SelectionCancelledError'); + if (promiseResult.errors[0]?.name === 'SelectionCancelledError') { + expect(promiseResult.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + await waitFor(() => result.current.errors.length === 1); + expect(result.current.errors[0]?.name).toBe('SelectionCancelledError'); + if (result.current.errors[0]?.name === 'SelectionCancelledError') { + expect(result.current.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + it('should respect overrides passed to openFilePicker', async () => { + const { input, result } = invokeUseFilePicker({ + readFilesContent: false, + doNotInvokeAutomatically: true, + }); + + const promise = result.current.openFilePicker({ + readFilesContent: true, + }); + + const files = [new File(['hello'], 'hello.png', { type: 'image/png' })]; + await userEvent.upload(input.current!, files); + + await waitFor(() => result.current.loading === false); + + const resultFromPromise = await promise; + expect(resultFromPromise?.plainFiles.length).toBe(1); + // this would be 0 but the override specified that we want to read the files content + expect(resultFromPromise?.filesContent.length).toBe(1); + + expect(result.current.plainFiles.length).toBe(1); + expect(result.current.filesContent.length).toBe(1); + expect(input.current!.files).toHaveLength(1); + expect(input.current!.files?.[0]).toStrictEqual(files[0]); + }); + + it('the promise result should contain errors when validation fails', async () => { + const validators = [new FileTypeValidator(['.nonexistent'])]; + const { input, result } = invokeUseFilePicker({ validators, doNotInvokeAutomatically: true }); + + const promise = result.current.openFilePicker(); + + const file = createFileOfSize(1024); + await userEvent.upload(input.current!, file); + + await waitFor(() => result.current.loading === false); + + const promiseResult = await promise; + expect(promiseResult.errors.length).toBe(1); + if (promiseResult.errors[0]?.name === 'FileTypeError') { + expect(promiseResult.errors[0]?.reason === 'FILE_TYPE_NOT_ACCEPTED').toBe(true); + } else { + throw new Error('Expected FileTypeError'); + } + + expect(result.current.plainFiles.length).toBe(0); + if (result.current.errors[0]?.name === 'FileTypeError') { + expect(result.current.errors[0]?.reason === 'FILE_TYPE_NOT_ACCEPTED').toBe(true); + } else { + throw new Error('Expected FileTypeError'); + } + }); +}); + +describe('PromisedBasedImperativeUseFilePicker', () => { + it('should return a promise from openFilePicker', async () => { + const user = userEvent.setup(); + const { input, result } = invokeUseImperativeFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker(); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + const files = [new File(['hello'], 'hello.png', { type: 'image/png' })]; + await user.upload(input.current!, files); + + await waitFor(() => result.current.loading === false); + + await waitFor(() => result.current.plainFiles.length === 1); + + expect(result.current.plainFiles.length).toBe(1); + expect(input.current!.files).toHaveLength(1); + expect(input.current!.files?.[0]).toStrictEqual(files[0]); // this is the file that was uploaded + const promiseResult = await promise; + expect(promiseResult.plainFiles.length).toBe(1); + expect(promiseResult.plainFiles[0]).toStrictEqual(files[0]); + + const promise2 = result.current.openFilePicker(); + + await waitFor(() => result.current.loading === true); + + const files2 = [new File(['hello2'], 'hello2.png', { type: 'image/png' })]; + await user.upload(input.current!, files2); + + await waitFor(() => result.current.loading === false); + + await waitFor(() => result.current.plainFiles.length === 2); + + const promiseResult2 = await promise2; + + expect(result.current.plainFiles.length).toBe(2); + expect(promiseResult2.plainFiles.length).toBe(2); + expect(promiseResult2.plainFiles[1]).toStrictEqual(files2[0]); + }); + + it('should also resolve the promise with empty arrays if the user cancels the selection', async () => { + const { input, result } = invokeUseImperativeFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker(); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(result => { + expect(result.plainFiles.length).toBe(0); + expect(result.filesContent.length).toBe(0); + expect(result.errors.length).toBe(0); + }); + }); + + it('should resolve the promise with the cancellation error if the user cancels the selection and detectCancellation is set to promiseResultOnly', async () => { + const { input, result } = invokeUseImperativeFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker({ detectCancellation: 'promiseResultOnly' }); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(result => { + expect(result.plainFiles.length).toBe(0); + expect(result.filesContent.length).toBe(0); + expect(result.errors.length).toBe(1); + expect(result.errors[0]?.name).toBe('SelectionCancelledError'); + if (result.errors[0]?.name === 'SelectionCancelledError') { + expect(result.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + await promise; + + // hook state doesn't show the error as user requested only the promise result to contain the error + expect(result.current.errors.length).toBe(0); + }); + + it('should resolve the promise with the cancellation error and return the error from the hook state if the user cancels the selection and detectCancellation is set to promiseResultAndHookState', async () => { + const { input, result } = invokeUseImperativeFilePicker({ doNotInvokeAutomatically: true }); + const promise = result.current.openFilePicker({ detectCancellation: 'promiseResultAndHookState' }); + + expect(promise).toBeInstanceOf(Promise); + + await waitFor(() => result.current.loading === true); + + input.current?.dispatchEvent(new MouseEvent('cancel')); + + await waitFor(() => result.current.loading === false); + + promise.then(promiseResult => { + expect(promiseResult.plainFiles.length).toBe(0); + expect(promiseResult.filesContent.length).toBe(0); + expect(promiseResult.errors.length).toBe(1); + expect(promiseResult.errors[0]?.name).toBe('SelectionCancelledError'); + if (promiseResult.errors[0]?.name === 'SelectionCancelledError') { + expect(promiseResult.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + await waitFor(() => result.current.errors.length === 1); + expect(result.current.errors[0]?.name).toBe('SelectionCancelledError'); + if (result.current.errors[0]?.name === 'SelectionCancelledError') { + expect(result.current.errors[0].reason).toBe('SELECTION_CANCELLED'); + } else { + throw new Error('Expected SelectionCancelledError'); + } + }); + + it('should respect overrides passed to openFilePicker', async () => { + const { input, result } = invokeUseImperativeFilePicker({ + readFilesContent: false, + doNotInvokeAutomatically: true, + }); + + const promise = result.current.openFilePicker({ + readFilesContent: true, + }); + + const files = [new File(['hello'], 'hello.png', { type: 'image/png' })]; + await userEvent.upload(input.current!, files); + + await waitFor(() => result.current.loading === false); + + const resultFromPromise = await promise; + expect(resultFromPromise?.plainFiles.length).toBe(1); + // this would be 0 but the override specified that we want to read the files content + expect(resultFromPromise?.filesContent.length).toBe(1); + + await waitFor(() => result.current.plainFiles.length === 1); + + expect(result.current.plainFiles.length).toBe(1); + expect(result.current.filesContent.length).toBe(1); + expect(input.current!.files).toHaveLength(1); + expect(input.current!.files?.[0]).toStrictEqual(files[0]); + }); + + it('the promise result should contain errors when validation fails', async () => { + const validators = [new FileTypeValidator(['.nonexistent'])]; + const { input, result } = invokeUseImperativeFilePicker({ validators, doNotInvokeAutomatically: true }); + + const promise = result.current.openFilePicker(); + + const file = createFileOfSize(1024); + await userEvent.upload(input.current!, file); + + await waitFor(() => result.current.loading === false); + + const promiseResult = await promise; + expect(promiseResult.errors.length).toBe(1); + if (promiseResult.errors[0]?.name === 'FileTypeError') { + expect(promiseResult.errors[0]?.reason === 'FILE_TYPE_NOT_ACCEPTED').toBe(true); + } else { + throw new Error('Expected FileTypeError'); + } + + expect(result.current.plainFiles.length).toBe(0); + if (result.current.errors[0]?.name === 'FileTypeError') { + expect(result.current.errors[0]?.reason === 'FILE_TYPE_NOT_ACCEPTED').toBe(true); + } else { + throw new Error('Expected FileTypeError'); + } + }); +}); diff --git a/packages/use-file-picker/test/testUtils.ts b/packages/use-file-picker/test/testUtils.ts index b770a87..6b012e9 100644 --- a/packages/use-file-picker/test/testUtils.ts +++ b/packages/use-file-picker/test/testUtils.ts @@ -16,7 +16,10 @@ export function createFileOfSize(sizeInBytes: number) { type UseFilePickerHook = typeof useFilePicker | typeof useImperativeFilePicker; -const invokeFilePicker = (props: UseFilePickerConfig, useFilePicker: UseFilePickerHook) => { +const invokeFilePicker = ( + props: UseFilePickerConfig & { doNotInvokeAutomatically?: boolean }, + useFilePicker: UseFilePickerHook +) => { const input: { current: HTMLInputElement | null } = { current: null }; const { result } = renderHook(() => @@ -30,11 +33,12 @@ const invokeFilePicker = (props: UseFilePickerConfig, useFilePicker: UseFilePick }) ) as { result: { current: ImperativeFilePickerReturnTypes> } }; - act(() => { - result.current.openFilePicker(); - }); - - if (!isInputElement(input.current!)) throw new Error('Input not found'); + if (!props.doNotInvokeAutomatically) { + act(() => { + result.current.openFilePicker(); + }); + if (!isInputElement(input.current!)) throw new Error('Input not found'); + } return { result, @@ -42,9 +46,12 @@ const invokeFilePicker = (props: UseFilePickerConfig, useFilePicker: UseFilePick }; }; -export const invokeUseFilePicker = (props: UseFilePickerConfig) => invokeFilePicker(props, useFilePicker); +export const invokeUseFilePicker = (props: UseFilePickerConfig & { doNotInvokeAutomatically?: boolean }) => + invokeFilePicker(props, useFilePicker); -export const invokeUseImperativeFilePicker = (props: T) => +export const invokeUseImperativeFilePicker = ( + props: T +) => invokeFilePicker(props, useImperativeFilePicker) as { result: { current: ImperativeFilePickerReturnTypes>;