@react-md/form
Version:
This package is for creating all the different form input types.
682 lines (620 loc) • 19.6 kB
text/typescript
import type { ChangeEventHandler, DragEventHandler } from "react";
import { nanoid } from "nanoid";
/** @remarks \@since 2.9.0 */
export interface BaseFileUploadStats {
/**
* A unique key associated with each upload generated by `nanoid`.
*/
key: string;
/**
* The file instance that is being uploaded.
*/
file: File;
/**
* The current upload progress as a percentage from 0 - 100 percent.
*/
progress: number;
}
/** @remarks \@since 2.9.0 */
export interface ProcessingFileUploadStats extends BaseFileUploadStats {
status: "pending" | "uploading";
}
/** @remarks \@since 2.9.0 */
export type FileReaderResult = FileReader["result"];
/** @remarks \@since 2.9.0 */
export interface CompletedFileUploadStats extends BaseFileUploadStats {
status: "complete";
/**
* The result after a `FileReader` has read a file completely.
*
* Note: This _should_ be an `ArrayBuffer` if the next step is to upload to a
* server.
*
* @see {@link FileReaderParser}
* @see {@link getFileParser}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/FileReader | FileReader}
*/
result: FileReaderResult;
}
/** @remarks \@since 2.9.0 */
export type FileUploadStats =
| ProcessingFileUploadStats
| CompletedFileUploadStats;
/** @remarks \@since 2.9.0 */
export type FileUploadStatus = FileUploadStats["status"];
/** @remarks \@since 2.9.0 */
export interface FileUploadHandlers<E extends HTMLElement> {
onDrop?: DragEventHandler<E>;
onChange?: ChangeEventHandler<HTMLInputElement>;
}
/**
* An error that will be created if a user tries dragging and dropping files
* from a shared directory that they do not have access to. This error will not
* occur much.
*
* @remarks \@since 2.9.0
*/
export class FileAccessError extends Error {
/**
* A unique key generated by `nanoid` that can be used as a `React` key
*/
public key: string;
/**
*
* @param message - An optional message for the error.
*/
constructor(message?: string) {
super(message);
this.key = nanoid();
this.name = "FileAccessError";
}
}
/**
* An error that just requires a `File` to be passed as the first argument.
*
* @remarks \@since 2.9.0
*/
export class GenericFileError extends Error {
/**
* A unique key generated by `nanoid` that can be used as a `React` key
*/
public key: string;
/**
*
* @param files - A list of files that caused the error.
* @param reason - An optional reason for the error
*/
constructor(public files: readonly File[], public reason?: string) {
super("Invalid files");
this.key = nanoid();
this.name = "GenericFileError";
}
}
/**
* An error that is created during the upload process if the number of files
* exceeds the {@link FileUploadOptions.maxFiles} amount.
*
* @remarks \@since 2.9.0
*/
export class TooManyFilesError extends GenericFileError {
/**
*
* @param files - The list of files that could not be uploaded due to the file
* limit defined.
* @param limit - The max limit of files allowed.
*/
constructor(files: readonly File[], public limit: number) {
super(files, "file limit");
this.name = "TooManyFilesError";
}
}
/**
* An error that will be created if a user tries to upload a file that
* is either:
* - less than the {@link FileValidationOptions.minFileSize}
* - greater than the {@link FileValidationOptions.maxFileSize}
* - including the file would be greater than the {@link FileValidationOptions.totalFileSize}
*
* @remarks \@since 2.9.0
*/
export class FileSizeError extends GenericFileError {
/**
*
* @param files - The list of files that have the file size error
* @param type - The file size error type
* @param limit - The number of bytes allowed based on the type
*/
constructor(
files: readonly File[],
public type: "min" | "max" | "total",
public limit: number
) {
super(files, "file size");
this.name = "FileSizeError";
}
}
/**
* An error that will be created if a user tries to upload a file that does not
* end with one of the {@link FileValidationOptions.extensions}.
*
* @remarks \@since 2.9.0
*/
export class FileExtensionError extends GenericFileError {
/**
*
* @param files - The file that caused the error
* @param extensions - The allowed list of file extensions
*/
constructor(files: readonly File[], public extensions: readonly string[]) {
super(files, "extension");
this.name = "FileExtensionError";
}
}
/**
* Mostly an internal type that is used to allow custom validation errors
*
* @remarks \@since 2.9.0
*/
export type FileValidationError<E = GenericFileError> =
| FileAccessError
| TooManyFilesError
| FileSizeError
| FileExtensionError
| E;
/**
* A simple type-guard that can be used to check if the
* {@link FileValidationError} is the {@link GenericFileError} which can be
* useful when displaying the errors to the user.
*
* @param error - The error to check
* @returns true if the error is a {@link FileAccessError}
*/
export function isGenericFileError<CustomError>(
error: FileValidationError<CustomError>
): error is GenericFileError {
return "name" in error && error.name === "GenericFileError";
}
/**
* A simple type-guard that can be used to check if the
* {@link FileValidationError} is the {@link FileAccessError} which can be
* useful when displaying the errors to the user.
*
* @param error - The error to check
* @returns true if the error is a {@link FileAccessError}
*/
export function isFileAccessError<CustomError>(
error: FileValidationError<CustomError>
): error is FileAccessError {
return "name" in error && error.name === "FileAccessError";
}
/**
* A simple type-guard that can be used to check if the
* {@link FileValidationError} is the {@link TooManyFilesError} which can be
* useful when displaying the errors to the user.
*
* @param error - The error to check
* @returns true if the error is a {@link TooManyFilesError}
*/
export function isTooManyFilesError<CustomError>(
error: FileValidationError<CustomError>
): error is TooManyFilesError {
return "name" in error && error.name === "TooManyFilesError";
}
/**
* A simple type-guard that can be used to check if the
* {@link FileValidationError} is the {@link FileSizeError} which can be
* useful when displaying the errors to the user.
*
* @param error - The error to check
* @returns true if the error is a {@link FileSizeError}
*/
export function isFileSizeError<CustomError>(
error: FileValidationError<CustomError>
): error is FileSizeError {
return "name" in error && error.name === "FileSizeError";
}
/**
* A simple type-guard that can be used to check if the
* {@link FileValidationError} is the {@link FileExtensionError} which can be
* useful when displaying the errors to the user.
*
* @param error - The error to check
* @returns true if the error is a {@link FileExtensionError}
*/
export function isFileExtensionError<CustomError>(
error: FileValidationError<CustomError>
): error is FileExtensionError {
return "name" in error && error.name === "FileExtensionError";
}
/**
* This function is used to determine if a file should be added to the
* {@link FileExtensionError}. The default implementation should work for most
* use cases except when files that do not have extensions can be uploaded. i.e.
* LICENSE files.
*
* @param file - The file being checked
* @param extensionRegExp - A regex that will only be defined if the
* `extensions` list had at least one value.
* @param extensions - The list of extensions allowed
* @returns true if the file has a valid name.
* @remarks \@since 3.1.0
*/
export type IsValidFileName = (
file: File,
extensionRegExp: RegExp | undefined,
extensions: readonly string[]
) => boolean;
/**
*
* @defaultValue `matcher?.test(file.name) ?? true`
* @remarks \@since 3.1.0
*/
export const isValidFileName: IsValidFileName = (file, matcher) =>
matcher?.test(file.name) ?? true;
/** @remarks \@since 2.9.0 */
export interface FileValidationOptions {
/**
* If the number of files should be limited, set this value to a number
* greater than `0`.
*
* Note: This still allows "infinite" files when set to `0` since the
* `<input>` element should normally be set to `disabled` if files should not
* be able to be uploaded.
*
* @defaultValue `-1`
*/
maxFiles?: number;
/**
* An optional minimum file size to enforce for each file. This will only be
* used when it is greater than `0`.
*
* @defaultValue `-1`
*/
minFileSize?: number;
/**
* An optional maximum file size to enforce for each file. This will only be
* used when it is greater than `0`.
*
* @defaultValue `-1`
*/
maxFileSize?: number;
/**
* An optional list of extensions to enforce when uploading files.
*
* Note: The extensions and file names will be compared ignoring case.
*
* @example
* Only Allow Images
* ```ts
* const extensions = ["png", "jpeg", "jpg", "gif"];
* ```
*/
extensions?: readonly string[];
/** {@inheritDoc IsValidFileName} */
isValidFileName?: IsValidFileName;
/**
* An optional total file size to enforce when the {@link maxFiles} option is
* not set to `1`.
*
* @defaultValue `-1`
*/
totalFileSize?: number;
}
/** @remarks \@since 2.9.0 */
export interface FilesValidationOptions
extends Required<FileValidationOptions> {
/**
* The total number of bytes in the {@link FileUploadHookReturnValue.stats}
* list. This is really just:
*
* ```ts
* const totalBytes = stats.reduce((total, stat) => total + stat.file.size, 0);
* ```
*/
totalBytes: number;
/**
* The total number of files in the {@link FileUploadHookReturnValue.stats}.
*/
totalFiles: number;
}
/** @remarks \@since 2.9.0 */
export interface ValidatedFilesResult<CustomError> {
/**
* A filtered list of files that have been validated and can be queued for the
* upload process.
*/
pending: readonly File[];
/**
* A list of {@link FileValidationError} that occurred during the validation
* step.
*
* Note: If an error has occurred, the file **should not** be added to the
* {@link pending} list of files.
*/
errors: readonly FileValidationError<CustomError>[];
}
/**
* This function will be called whenever a file has been uploaded by the user
* either through an `<input type="file">` or drag and drop behavior.
*
* @example
* Simple Example
* ```ts
* const validateFiles: FilesValidator = (files, options) => {
* const invalid: File[] = [];
* const pending: File[] = [];
* for (const file of files) {
* if (!/\.(jpe?g|svg|png)$/i.test(name)) {
* invalid.push(file);
* } else {
* pending.push(file);
* }
* }
*
* const errors: FileValidationError[] = [];
* if (invalid.length) {
* errors.push(new GenericFileError(invalid))
* }
*
* return { pending, errors };
* };
* ```
*
* @typeparam E - An optional custom file validation error.
* @param files - The list of files to check
* @param options - The {@link FilesValidationOptions}
* @returns the {@link ValidatedFilesResult}
* @see {@link validateFiles} for the default implementation
* @remarks \@since 2.9.0
*/
export type FilesValidator<CustomError = never> = (
files: readonly File[],
options: FilesValidationOptions
) => ValidatedFilesResult<CustomError>;
/**
* A pretty decent default implementation for validating files with the
* {@link useFileUpload} that ensures the {@link FilesValidationOptions} are
* enforced before allowing a file to be uploaded.
*
* @typeparam E - An optional custom file validation error.
* @param files - The list of files to check
* @param options - The {@link FilesValidationOptions}
* @returns the {@link ValidatedFilesResult}
* @remarks \@since 2.9.0
*/
export function validateFiles<CustomError>(
files: readonly File[],
{
maxFiles,
extensions,
minFileSize,
maxFileSize,
totalBytes,
totalFiles,
totalFileSize,
isValidFileName,
}: FilesValidationOptions
): ValidatedFilesResult<CustomError> {
const errors: FileValidationError<CustomError>[] = [];
const pending: File[] = [];
const extraFiles: File[] = [];
const extensionRegExp =
extensions.length > 0
? new RegExp(`\\.(${extensions.join("|")})$`, "i")
: undefined;
let maxFilesReached = maxFiles > 0 && totalFiles >= maxFiles;
let remainingBytes = totalFileSize - totalBytes;
const extensionErrors: File[] = [];
const minErrors: File[] = [];
const maxErrors: File[] = [];
const totalSizeErrors: File[] = [];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
let valid = true;
const { size } = file;
if (!isValidFileName(file, extensionRegExp, extensions)) {
valid = false;
extensionErrors.push(file);
}
if (minFileSize > 0 && size < minFileSize) {
valid = false;
minErrors.push(file);
}
if (maxFileSize > 0 && size > maxFileSize) {
valid = false;
maxErrors.push(file);
} else if (totalFileSize > 0 && remainingBytes - file.size < 0) {
// don't want both errors displaying
valid = false;
totalSizeErrors.push(file);
}
if (maxFilesReached && valid) {
extraFiles.push(file);
} else if (!maxFilesReached && valid) {
pending.push(file);
remainingBytes -= file.size;
maxFilesReached =
maxFilesReached ||
(maxFiles > 0 && totalFiles + pending.length >= maxFiles);
}
}
if (extensionErrors.length) {
errors.push(new FileExtensionError(extensionErrors, extensions));
}
if (minErrors.length) {
errors.push(new FileSizeError(minErrors, "min", minFileSize));
}
if (maxErrors.length) {
errors.push(new FileSizeError(maxErrors, "max", maxFileSize));
}
if (totalSizeErrors.length) {
errors.push(new FileSizeError(totalSizeErrors, "total", totalFileSize));
}
if (extraFiles.length) {
errors.push(new TooManyFilesError(extraFiles, maxFiles));
}
return { pending, errors };
}
/**
* This will first check if the mime-type of the file starts with `text/` and
* fallback to checking a few file names or extensions that should be considered
* text.
*
* This function is not guaranteed to be 100% correct and is only useful if
* trying to generate a preview of files uploaded to the browser.
*
* @param file - The file to check
* @returns `true` if the file should be considered as a text-content file.
* @remarks \@since 2.9.0
*/
export function isTextFile(file: File): boolean {
return /\.((j|t)sx?|json|lock|hbs|ya?ml|log|txt|md)$/i.test(file.name);
}
/**
* This will first check if the mime-type of the file starts with `text\/` and
* fallback to checking a few file names or extensions that should be considered
* text.
*
* This function is not guaranteed to be 100% correct and is only useful if
* trying to generate a preview of files uploaded to the browser.
*
* @param file - The file to check
* @returns `true` if the file should be considered as a text content file.
* @remarks \@since 2.9.0
*/
export function isImageFile(file: File): boolean {
return /\.(a?png|avif|svg|tiff|gifv?|jpe?g)/i.test(file.name);
}
/**
* This will first check if the mime-type of the file starts with `audio/` and
* fallback to checking a few file names or extensions that should be considered
* audio.
*
* This function is not guaranteed to be 100% correct and is only useful if
* trying to generate a preview of files uploaded to the browser.
*
* @param file - The file to check
* @returns `true` if the file should be considered as a audio content file.
* @remarks \@since 2.9.0
*/
export function isAudioFile(file: File): boolean {
return /\.(mp3|wav|ogg|m4p|flac)$/i.test(file.name);
}
/**
* This will first check if the mime-type of the file starts with `video/` and
* fallback to checking a few file names or extensions that should be considered
* video.
*
* This function is not guaranteed to be 100% correct and is only useful if
* trying to generate a preview of files uploaded to the browser.
*
* @param file - The file to check
* @returns `true` if the file should be considered as a video content file.
* @remarks \@since 2.9.0
*/
export function isVideoFile(file: File): boolean {
return /\.(mkv|mpe?g|mov|avi|flv|webm|mp4)$/i.test(file.name);
}
/**
* This function is not guaranteed to be 100% correct and is only useful if
* trying to generate a preview of files uploaded to the browser.
*
* @param file - The file to check
* @returns `true` if the file matches an image, audio, or video file.
* @remarks \@since 2.9.0
*/
export function isMediaFile(file: File): boolean {
return isImageFile(file) || isAudioFile(file) || isVideoFile(file);
}
/**
* One of the function names from a `FileReader` to upload a file to the
* client.
*
* Note: If this file does not need to be previewed in the browser and will
* immediately be uploaded to a server, use `readAsArrayBuffer`.
*
* @remarks \@since 2.9.0
*/
export type FileReaderParser =
| "readAsText"
| "readAsDataURL"
| "readAsBinaryString"
| "readAsArrayBuffer";
/**
* A function that should return one of the {@link FileReaderParser} functions
* to start uploading a file to the browser.
*
* @example
* The Default File Upload Parser
* ```ts
* export const getFileParser: GetFileParser = (file) => {
* if (isMediaFile(file)) {
* return "readAsDataURL";
* }
*
* if (isTextFile(file)) {
* return "readAsText";
* }
*
* return "readAsArrayBuffer";
* };
* ```
*
* @param file - The file to get a parser for
* @returns the {@link FileReaderParser} string.
* @remarks \@since 2.9.0
*/
export type GetFileParser = (file: File) => FileReaderParser;
/**
* This function will attempt to read:
* - media (image, audio, and video) files as a data url so they can be
* previewed in `<img>`, `<audio>`, and `<video>` tags
* - text files as plain text
* - everything else as an `ArrayBuffer` which can be manually converted into a
* data url if needed with `URL.createObjectURL`
*
* @remarks \@since 2.9.0
*/
export const getFileParser: GetFileParser = (file) => {
if (isMediaFile(file)) {
return "readAsDataURL";
}
if (isTextFile(file)) {
return "readAsText";
}
return "readAsArrayBuffer";
};
/** @remarks \@since 2.9.0 */
export interface SplitFileUploads {
readonly pending: readonly ProcessingFileUploadStats[];
readonly uploading: readonly ProcessingFileUploadStats[];
readonly complete: readonly CompletedFileUploadStats[];
}
/**
* This util will split all the current upload stats by status.
*
* @param stats - The {@link FileUploadStats} list generally returned by the
* {@link useFileUpload} hook.
* @returns the {@link SplitFileUploads}.
* @remarks \@since 2.9.0
*/
export function getSplitFileUploads(
stats: readonly FileUploadStats[]
): SplitFileUploads {
const pending: ProcessingFileUploadStats[] = [];
const uploading: ProcessingFileUploadStats[] = [];
const complete: CompletedFileUploadStats[] = [];
stats.forEach((stat) => {
if (stat.status === "pending") {
pending.push(stat);
} else if (stat.status === "uploading") {
uploading.push(stat);
} else if (stat.status === "complete") {
complete.push(stat);
} else {
/* istanbul ignore next */
throw new Error("Invalid upload stat");
}
});
return { pending, uploading, complete };
}