Skip to content

Commit dbd0375

Browse files
committed
feat(form): add isValidFileName option to useFileUpload
This allows files without extensions like LICENSE to be uploaded with other text-like files
1 parent 9238140 commit dbd0375

4 files changed

Lines changed: 103 additions & 3 deletions

File tree

packages/form/src/file-input/__tests__/useFileUpload.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
FileValidationError,
3737
isFileSizeError,
3838
isTooManyFilesError,
39+
IsValidFileName,
40+
isValidFileName,
3941
TooManyFilesError,
4042
} from "../utils";
4143

@@ -633,9 +635,69 @@ describe("useFileUpload", () => {
633635
totalBytes: 0,
634636
totalFiles: 0,
635637
totalFileSize: MAX_UPLOAD_SIZE,
638+
isValidFileName,
636639
});
637640
});
638641

642+
it("should allow for a custom isValidFileName so that files without extensions can be uploaded", () => {
643+
const allowExtensionsAndLicense: IsValidFileName = (
644+
file,
645+
extensionRegExp,
646+
extensions
647+
) =>
648+
isValidFileName(file, extensionRegExp, extensions) ||
649+
/^LICENSE$/i.test(file.name);
650+
651+
const customIsValidFileName = jest.fn(allowExtensionsAndLicense);
652+
const extensions = ["md", "txt"];
653+
const extensionRegExp = new RegExp("\\.(md|txt)$", "i");
654+
655+
const { getByLabelText } = renderComplex({
656+
extensions,
657+
isValidFileName: customIsValidFileName,
658+
});
659+
660+
const input = getByLabelText(/Upload/) as HTMLInputElement;
661+
expect(customIsValidFileName).not.toBeCalled();
662+
663+
const md = createFile("file2.md", 1024);
664+
userEvent.upload(input, md);
665+
expect(customIsValidFileName).toBeCalledWith(
666+
md,
667+
extensionRegExp,
668+
extensions
669+
);
670+
expect(getErrorDialog).toThrow();
671+
672+
const txt = createFile("file2.txt", 1024);
673+
userEvent.upload(input, txt);
674+
expect(customIsValidFileName).toBeCalledWith(
675+
txt,
676+
extensionRegExp,
677+
extensions
678+
);
679+
expect(getErrorDialog).toThrow();
680+
681+
const license = createFile("LICENSE", 1024);
682+
userEvent.upload(input, license);
683+
expect(customIsValidFileName).toBeCalledWith(
684+
license,
685+
extensionRegExp,
686+
extensions
687+
);
688+
expect(getErrorDialog).toThrow();
689+
690+
const png = createFile("file1.png", 1024);
691+
userEvent.upload(input, png);
692+
expect(customIsValidFileName).toBeCalledWith(
693+
png,
694+
extensionRegExp,
695+
extensions
696+
);
697+
expect(getErrorDialog).not.toThrow();
698+
fireEvent.click(getByRoleGlobal(document.body, "button", { name: "Okay" }));
699+
});
700+
639701
it("should throw a TooManyFilesError if too many files are uploaded", () => {
640702
const { getByLabelText, getByText } = renderComplex({ maxFiles: 1 });
641703
const input = getByLabelText(/Upload/) as HTMLInputElement;

packages/form/src/file-input/__tests__/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isFileAccessError,
1010
isFileExtensionError,
1111
isGenericFileError,
12+
isValidFileName,
1213
validateFiles,
1314
} from "../utils";
1415

@@ -145,6 +146,7 @@ describe("validateFiles", () => {
145146
totalBytes: 0,
146147
totalFiles: 0,
147148
totalFileSize: 1024,
149+
isValidFileName,
148150
});
149151

150152
const result2 = validateFiles([file1, file3], {
@@ -155,6 +157,7 @@ describe("validateFiles", () => {
155157
totalBytes: 0,
156158
totalFiles: 0,
157159
totalFileSize: 2000,
160+
isValidFileName,
158161
});
159162

160163
expect(result1).toEqual({

packages/form/src/file-input/useFileUpload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
FilesValidator,
2020
GetFileParser,
2121
ProcessingFileUploadStats,
22+
isValidFileName as defaultIsValidFileName,
2223
validateFiles as defaultValidateFiles,
2324
FileValidationOptions,
2425
} from "./utils";
@@ -220,6 +221,7 @@ export function useFileUpload<E extends HTMLElement, CustomError = never>({
220221
onChange: propOnChange,
221222
validateFiles = defaultValidateFiles,
222223
getFileParser = defaultGetFileParser,
224+
isValidFileName = defaultIsValidFileName,
223225
}: FileUploadOptions<E, CustomError> = {}): Readonly<
224226
FileUploadHookReturnValue<E, CustomError>
225227
> {
@@ -364,6 +366,7 @@ export function useFileUpload<E extends HTMLElement, CustomError = never>({
364366
totalBytes,
365367
totalFiles,
366368
totalFileSize,
369+
isValidFileName,
367370
});
368371

369372
dispatch({ type: "queue", errors, files: pending });
@@ -377,6 +380,7 @@ export function useFileUpload<E extends HTMLElement, CustomError = never>({
377380
totalBytes,
378381
totalFiles,
379382
totalFileSize,
383+
isValidFileName,
380384
]
381385
);
382386
const onDrop = useCallback(

packages/form/src/file-input/utils.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,33 @@ export function isFileExtensionError<CustomError>(
250250
return "name" in error && error.name === "FileExtensionError";
251251
}
252252

253+
/**
254+
* This function is used to determine if a file should be added to the
255+
* {@link FileExtensionError}. The default implementation should work for most
256+
* use cases except when files that do not have extensions can be uploaded. i.e.
257+
* LICENSE files.
258+
*
259+
* @param file - The file being checked
260+
* @param extensionRegExp - A regex that will only be defined if the
261+
* `extensions` list had at least one value.
262+
* @param extensions - The list of extensions allowed
263+
* @returns true if the file has a valid name.
264+
* @remarks \@since 3.1.0
265+
*/
266+
export type IsValidFileName = (
267+
file: File,
268+
extensionRegExp: RegExp | undefined,
269+
extendsions: readonly string[]
270+
) => boolean;
271+
272+
/**
273+
*
274+
* @defaultValue `matcher?.test(file.name) ?? true`
275+
* @remarks \@since 3.1.0
276+
*/
277+
export const isValidFileName: IsValidFileName = (file, matcher) =>
278+
matcher?.test(file.name) ?? true;
279+
253280
/** @remarks \@since 2.9.0 */
254281
export interface FileValidationOptions {
255282
/**
@@ -293,6 +320,9 @@ export interface FileValidationOptions {
293320
*/
294321
extensions?: readonly string[];
295322

323+
/** {@inheritDoc IsValidFileName} */
324+
isValidFileName?: IsValidFileName;
325+
296326
/**
297327
* An optional total file size to enforce when the {@link maxFiles} option is
298328
* not set to `1`.
@@ -399,12 +429,13 @@ export function validateFiles<CustomError>(
399429
totalBytes,
400430
totalFiles,
401431
totalFileSize,
432+
isValidFileName,
402433
}: FilesValidationOptions
403434
): ValidatedFilesResult<CustomError> {
404435
const errors: FileValidationError<CustomError>[] = [];
405436
const pending: File[] = [];
406437
const extraFiles: File[] = [];
407-
const nameRegExp =
438+
const extensionRegExp =
408439
extensions.length > 0
409440
? new RegExp(`\\.(${extensions.join("|")})$`, "i")
410441
: undefined;
@@ -423,8 +454,8 @@ export function validateFiles<CustomError>(
423454
}
424455

425456
let valid = true;
426-
const { name, size } = file;
427-
if (nameRegExp && !nameRegExp.test(name)) {
457+
const { size } = file;
458+
if (!isValidFileName(file, extensionRegExp, extensions)) {
428459
valid = false;
429460
extensionErrors.push(file);
430461
}

0 commit comments

Comments
 (0)