@react-md/form
Version:
This package is for creating all the different form input types.
544 lines (506 loc) • 14.6 kB
text/typescript
import type { ChangeEvent, DragEvent } from "react";
import { useCallback, useEffect, useReducer } from "react";
import { nanoid } from "nanoid";
import { omit } from "@react-md/utils";
import type {
CompletedFileUploadStats,
FileReaderResult,
FileUploadHandlers,
FileUploadStats,
FileValidationError,
FilesValidator,
GetFileParser,
ProcessingFileUploadStats,
FileValidationOptions,
} from "./utils";
import {
getFileParser as defaultGetFileParser,
FileAccessError,
isValidFileName as defaultIsValidFileName,
validateFiles as defaultValidateFiles,
} from "./utils";
/**
*
* @typeParam CustomError - An optional error type that gets returned from the
* {@link FilesValidator}.
* @remarks \@since 2.9.0
*/
export interface FileUploadState<CustomError = never> {
/**
* All the files that have been validated and are either:
* - pending upload
* - uploading
* - complete
*
* Each key in this object is the {@link BaseFileUploadStats.key} generated
* once the upload starts pending.
*/
stats: Readonly<Record<string, Readonly<FileUploadStats>>>;
/**
* A list of validation errors that have occurred before starting the upload
* process.
*
* @see {@link FileAccessError}
* @see {@link TooManyFilesError}
* @see {@link FileValidationError}
*/
errors: readonly FileValidationError<CustomError>[];
}
/**
*
* @typeParam CustomError - An optional error type that gets returned from the
* {@link FilesValidator}.
* @remarks \@since 2.9.0
* @internal
*/
export interface FileUploadHookState<CustomError = never>
extends FileUploadState<CustomError> {
/**
* All the current readers used for uploading files to the browser.
*
* Note: Once an upload has completed, the reader will be removed.
*/
readers: Readonly<Record<string, FileReader>>;
}
/**
*
* @typeParam E - An optional HTMLElement type that is used for the
* {@link FileUploadHandlers}.
* @typeParam CustomError - An optional error type that gets returned from the
* {@link FilesValidator}.
* @remarks \@since 2.9.0
*/
export interface FileUploadOptions<E extends HTMLElement, CustomError = never>
extends FileUploadHandlers<E>,
FileValidationOptions {
/**
* Setting this value to a number greater than `0` will update the browser
* upload process to queue the uploads in chunks instead of all at once. This
* can help prevent the browser from freezing if dealing with large files that
* are being converted to data urls.
*
* @defaultValue `-1`
*/
concurrency?: number;
/** {@inheritDoc FilesValidator} */
validateFiles?: FilesValidator<CustomError>;
/** {@inheritDoc GetFileParser} */
getFileParser?: GetFileParser;
}
/** @internal */
type Action<E = never> =
| {
type: "queue";
errors: readonly FileValidationError<E>[];
files: readonly File[];
}
| { type: "reset" }
| { type: "remove"; files: readonly string[] }
| { type: "start"; key: string; reader: FileReader }
| { type: "progress"; key: string; progress: number }
| { type: "complete"; key: string; result: FileReaderResult }
| { type: "clearErrors" };
/** @remarks \@since 2.9.0 */
export interface FileUploadActions {
/**
* Reset everything related to uploads ensuring that all file readers have
* been aborted.
*/
reset(): void;
/**
* Removes all the errors that exist in state without cancelling any of the
* uploads already in progress.
*/
clearErrors(): void;
/**
* This function is used to cancel pending and uploading files or removing
* completed files.
*
* @param keyOrKeys - A single or list of {@link BaseFileUploadStats.key} to
* remove from state.
*/
remove(keyOrKeys: string | readonly string[]): void;
}
/**
*
* @typeParam E - An optional HTMLElement type that is used for the
* {@link FileUploadHandlers}.
* @typeParam CustomError - An optional error type that gets returned from the
* {@link FilesValidator}.
* @remarks \@since 2.9.0
*/
export interface FileUploadHookReturnValue<
E extends HTMLElement = HTMLElement,
CustomError = never
> extends FileUploadActions,
Required<FileUploadHandlers<E>> {
/** {@inheritDoc FileUploadState.errors} */
errors: readonly FileValidationError<CustomError>[];
/**
* A list of all the {@link FileUploadStats}.
*
* @see {@link getSplitFileUploads} for separating by status
*/
stats: readonly Readonly<FileUploadStats>[];
/**
* The total number of bytes for all the files that exist in the
* {@link stats} list.
*/
totalBytes: number;
/**
* The total number of files in the {@link stats} list.
*/
totalFiles: number;
/**
* An `accept` string that can be passed to the {@link FileInput} component
* when the {@link FileValidationOptions.extensions} list has been provided to
* limit which files the OS will _attempt_ to allow access to.
*
* @example
* Simple example
* ```ts
* const extensions = ['pdf', 'docx', 'ppt'];
* const { accept } = useFileUpload({ extensions, ...others });
*
* expect(accept).toBe("*.pdf,*.docx,*.ppt")
* ```
*
* @defaultValue `"*"`
*/
accept: string;
}
/** @internal */
const EMPTY_LIST = [] as const;
/** @internal */
const EMPTY_OBJECT = {} as const;
/**
* This hook is generally used to upload files **to the browser** in different
* formats to be previewed `<img>`, `<video>`, `<embed>`, etc tags. However, it
* can also be used to upload the files as an `ArrayBuffer` and then uploaded to
* a server.
*
* Note: If using the `aws-sdk` to upload files directly to S3, **do not use
* this hook** since it uses its own upload process.
*
* @typeParam E - An optional HTMLElement type that is used for the
* {@link FileUploadHandlers}.
* @typeParam CustomError - An optional error type that gets returned from the
* {@link FilesValidator}.
* @param options - All the {@link FileUploadOptions}
* @returns the {@link FileUploadHookReturnValue}
* @remarks \@since 2.9.0
*/
export function useFileUpload<E extends HTMLElement, CustomError = never>({
maxFiles = -1,
extensions = EMPTY_LIST,
minFileSize = -1,
maxFileSize = -1,
totalFileSize = -1,
concurrency = -1,
onDrop: propOnDrop,
onChange: propOnChange,
validateFiles = defaultValidateFiles,
getFileParser = defaultGetFileParser,
isValidFileName = defaultIsValidFileName,
}: FileUploadOptions<E, CustomError> = {}): Readonly<
FileUploadHookReturnValue<E, CustomError>
> {
const [state, dispatch] = useReducer(
function reducer(
state: FileUploadHookState<CustomError>,
action: Action<CustomError>
) {
switch (action.type) {
case "reset":
// need to reuse constants so that calling reset doesn't cause an
// infinite loop in an effect
return {
stats: EMPTY_OBJECT,
errors: EMPTY_LIST,
readers: EMPTY_OBJECT,
};
case "remove":
return {
...state,
stats: omit(state.stats, action.files),
};
case "queue":
return {
...state,
stats: {
...state.stats,
...action.files.reduce<Record<string, ProcessingFileUploadStats>>(
(files, file) => {
const key = nanoid();
files[key] = {
key,
file,
progress: 0,
status: "pending",
};
return files;
},
{}
),
},
errors: [...state.errors, ...action.errors],
};
case "start": {
const { key, reader } = action;
/* istanbul ignore next */
if (!state.stats[key]) {
throw new Error(`Missing file with key "${key}"`);
}
const fileStats: ProcessingFileUploadStats = {
key,
file: state.stats[key].file,
progress: 0,
status: "uploading",
};
return {
...state,
readers: {
...state.readers,
[key]: reader,
},
stats: {
...state.stats,
[key]: fileStats,
},
};
}
case "progress": {
const { key, progress } = action;
/* istanbul ignore next */
if (!state.stats[key]) {
throw new Error(`Missing file with key "${key}"`);
}
return {
...state,
stats: {
...state.stats,
[key]: {
...state.stats[key],
progress,
},
},
};
}
case "complete": {
const { key, result } = action;
/* istanbul ignore next */
if (!state.stats[key]) {
throw new Error(`Missing file with key "${key}"`);
}
const file: CompletedFileUploadStats = {
key,
file: state.stats[key].file,
status: "complete",
result,
progress: 100,
};
const { [key]: _reader, ...readers } = state.readers;
return {
...state,
readers,
stats: {
...state.stats,
[key]: file,
},
};
}
case "clearErrors":
return { ...state, errors: [] };
default:
/* istanbul ignore next */
return state;
}
},
{
stats: EMPTY_OBJECT,
errors: EMPTY_LIST,
readers: EMPTY_OBJECT,
}
);
const { stats, errors, readers } = state;
const statsList = Object.values(stats);
const totalFiles = statsList.length;
const totalBytes = statsList.reduce(
(result, { file: { size } }) => result + size,
0
);
const queueFiles = useCallback(
(files: readonly File[]) => {
const { pending, errors } = validateFiles(files, {
maxFiles,
extensions,
minFileSize,
maxFileSize,
totalBytes,
totalFiles,
totalFileSize,
isValidFileName,
});
dispatch({ type: "queue", errors, files: pending });
},
[
validateFiles,
maxFiles,
extensions,
minFileSize,
maxFileSize,
totalBytes,
totalFiles,
totalFileSize,
isValidFileName,
]
);
const onDrop = useCallback(
(event: DragEvent<E>) => {
propOnDrop?.(event);
event.preventDefault();
event.stopPropagation();
try {
const files = event.dataTransfer.files;
if (files) {
queueFiles(Array.from(files));
}
} catch (e) {
dispatch({
type: "queue",
files: [],
errors: [
new FileAccessError(e instanceof Error ? e.message : undefined),
],
});
}
},
[queueFiles, propOnDrop]
);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
propOnChange?.(event);
try {
const files = event.currentTarget.files;
if (files) {
queueFiles(Array.from(files));
} else {
throw new Error();
}
} catch (e) {
dispatch({
type: "queue",
files: [],
errors: [
new FileAccessError(e instanceof Error ? e.message : undefined),
],
});
}
},
[queueFiles, propOnChange]
);
const remove = useCallback(
(keyOrKeys: string | readonly string[]) => {
const files = typeof keyOrKeys === "string" ? [keyOrKeys] : keyOrKeys;
files.forEach((fileKey) => {
readers[fileKey]?.abort();
});
dispatch({ type: "remove", files });
},
[readers]
);
const reset = useCallback(() => {
Object.values(readers).forEach((reader) => {
reader.abort();
});
dispatch({ type: "reset" });
}, [readers]);
const clearErrors = useCallback(() => {
dispatch({ type: "clearErrors" });
}, []);
const start = useCallback((key: string, reader: FileReader) => {
dispatch({ type: "start", key, reader });
}, []);
const complete = useCallback(
(key: string, result: FileReaderResult = null) => {
dispatch({ type: "complete", key, result });
},
[]
);
const createProgressEventHandler = useCallback(
(key: string) => (event: ProgressEvent) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded * 100) / event.total);
dispatch({ type: "progress", key, progress: percentage });
}
},
[]
);
useEffect(() => {
const pending: ProcessingFileUploadStats[] = [];
const uploading: ProcessingFileUploadStats[] = [];
Object.values(stats).forEach((file) => {
if (file.status === "pending") {
pending.push(file);
} else if (file.status === "uploading") {
uploading.push(file);
}
});
const lastIndex =
concurrency === -1
? pending.length
: Math.max(0, concurrency - uploading.length);
const queue = pending.slice(0, lastIndex);
if (!queue.length) {
return;
}
queue.forEach((stats) => {
const { key, file } = stats;
const reader = new FileReader();
// using `addEventListener` instead of directly setting to
// `reader.progress`/`reader.load` so it's easier to test
reader.addEventListener("progress", createProgressEventHandler(key));
reader.addEventListener("load", () => {
complete(key, reader.result);
});
start(key, reader);
const parser = getFileParser(file);
/* istanbul ignore next */
if (
process.env.NODE_ENV !== "production" &&
![
"readAsText",
"readAsDataURL",
"readAsArrayBuffer",
"readAsBinaryString",
].includes(parser)
) {
throw new Error("Invalid file reader parser");
}
reader[parser](file);
});
}, [
concurrency,
stats,
getFileParser,
createProgressEventHandler,
start,
complete,
]);
let accept = "";
if (extensions.length) {
accept = extensions.reduce((s, ext) => `${s ? `${s},` : ""}.${ext}`, "");
}
return {
stats: statsList,
errors,
accept,
totalBytes,
totalFiles,
onDrop,
onChange,
reset,
remove,
clearErrors,
};
}