diff --git a/community/components/form/file-dropzone/file-dropzone.component.html b/community/components/form/file-dropzone/file-dropzone.component.html index 15f84538..9f95b18c 100644 --- a/community/components/form/file-dropzone/file-dropzone.component.html +++ b/community/components/form/file-dropzone/file-dropzone.component.html @@ -37,7 +37,6 @@
- {{ file.label ?? file.name }} + {{ file.label || file.name }} @if (file && validateIndividually() && file.helper; as helper) { (false); - /** - * Validation functions that can be used to validate files. - * Each function should return a string error message if validation fails, or undefined if it passes - * Validators are only added once during component initialization, otherwise use addAsyncValidators on the FormControl. - */ - validators = input([ - validateFileSize, - validateFileType, - ]); /** * If true, shows the file dropzone as in a erroring state with red border. * Overrides default validation state. @@ -230,7 +218,17 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { this.addFiles(this.defaultFiles()); this._fileService.mode = this.mode; const control = this._ngControl.control; - control?.addAsyncValidators(this.runValidators); + control?.addValidators([ + validateFileSize( + this.maxSize(), + this.sizeDisplayStandard(), + this._translationService.translate.bind(this._translationService), + ), + validateFileType( + this.accept(), + this._translationService.translate.bind(this._translationService), + ), + ]); this._ngControl.control?.statusChanges ?.pipe(takeUntilDestroyed(this._destroyRef)) @@ -242,63 +240,6 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { this.uploadState.set(this._getNewState()); } - runValidators = async ( - control: AbstractControl, - ): Promise => { - const controlFiles = control.value; - - if (!controlFiles || !controlFiles.length) { - return null; - } - - const issues = this.validateFiles(controlFiles); - - return issues.length - ? { - tediFileDropzone: [...issues], - } - : null; - }; - - validateFiles(files: FileDropzone[]): FileDropzoneErrors { - const issues: FileDropzoneErrors = []; - files.forEach((file) => { - this.validators().forEach((validator) => { - const error = validator( - this.maxSize(), - this.accept(), - file, - this.sizeDisplayStandard(), - this._translationService.translate.bind(this._translationService), - ); - - if (error && error.code) { - issues.push(error); - file.fileStatus = "invalid"; - file.helper = { - text: error.message, - type: "error", - }; - } else if (!issues.find((issue) => issue.fileName === file.name)) { - file.fileStatus = "valid"; - file.helper = undefined; - } - }); - }); - return issues; - } - - fileClasses = (file: FileDropzone): string => { - const classList = ["tedi-file-dropzone__file-item"]; - if (file.className) { - classList.push(...file.className); - } - if (file.fileStatus != "none") { - classList.push(`tedi-file-dropzone__file-item--${file.fileStatus}`); - } - return classList.join(" "); - }; - tooltipClasses = (file: FileDropzone): string => { const classes = ["tedi-file-dropzone__tooltip"]; classes.push(`tedi-file-dropzone__tooltip--${file.helper?.type || "hint"}`); @@ -374,22 +315,22 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { } private _currentErrorState(): string | undefined { - const errors = this._ngControl.control?.errors?.["tediFileDropzone"]; + const errors: FormControlErrors = this._ngControl.control?.errors; if (errors && !this.validateIndividually()) { - const dropzoneErrors: FileDropzoneErrors = errors; + const dropzoneErrors = Object.values(errors); + return dropzoneErrors.map((error) => error.message).join(" "); } return undefined; } private _getNewState(): ValidationState { - const errors: FileDropzoneErrors = - this._ngControl.control?.errors?.["tediFileDropzone"]; + const errors: FormControlErrors = this._ngControl.control?.errors; if (this._ngControl.control?.touched) { return "none"; } - if (errors?.length) { + if (errors) { return "invalid"; } return this.files().length > 0 ? "valid" : "none"; diff --git a/community/components/form/file-dropzone/file-dropzone.stories.ts b/community/components/form/file-dropzone/file-dropzone.stories.ts index d88c94ad..2e9d45ad 100644 --- a/community/components/form/file-dropzone/file-dropzone.stories.ts +++ b/community/components/form/file-dropzone/file-dropzone.stories.ts @@ -6,8 +6,12 @@ import { moduleMetadata, StoryObj, } from "@storybook/angular"; -import { validateFileSize, validateFileType } from "./utils"; -import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; import { ButtonComponent } from "tedi/components"; /** @@ -53,7 +57,6 @@ const meta: Meta = { mode: "append", name: "file-dropzone", uploadFolder: false, - validators: [validateFileSize, validateFileType], hasError: false, }, argTypes: { @@ -112,11 +115,6 @@ const meta: Meta = { uploadFolder: { description: ` If true, allows uploading folders instead of just files. This enables the user to select a folder and upload all its contents. Default file browser behaviour will prevent upload of files in this state.`, }, - validators: { - control: false, - description: - "Validation functions that can be used to validate files. Each function should return a string error message if validation fails, or undefined if it passes.", - }, hasError: { description: `If true, shows the file dropzone as in a erroring state with red border. Overrides default validation state.`, @@ -142,7 +140,9 @@ const Template = (args: StoryArgs) => ` export const Default: Story = { render: (args) => { const form = new FormGroup({ - files: new FormControl(null), + files: new FormControl(null, { + validators: [Validators.maxLength(1)], + }), }); const logValue = () => { @@ -150,7 +150,7 @@ export const Default: Story = { form.get("files")?.value, form.controls.files, form.controls, - form.controls.files.errors + form.controls.files.errors, ); }; return { @@ -164,9 +164,28 @@ export const Default: Story = { }, }; +/** Limits the maximum amount of files to 1 */ +export const MaxFileAmount: Story = { + render: (args) => { + const form = new FormGroup({ + files: new FormControl(null, { + validators: [Validators.maxLength(1)], + }), + }); + + return { + template: Template(args), + props: { ...args, form }, + }; + }, + args: { + inputId: "file-dropzone-form-control", + name: "file-form-control", + }, +}; /** Replaces any same-name files, when usually it indexes them. */ export const Replace: Story = { - render: (args) => ({ template: Template(args), props: args }), + ...Default, args: { mode: "replace", }, @@ -174,7 +193,7 @@ export const Replace: Story = { /** Custom un-translated label text. */ export const WithHint: Story = { - render: (args) => ({ template: Template(args), props: args }), + ...Default, args: { name: "file", label: "Custom hint here", diff --git a/community/components/form/file-dropzone/types.ts b/community/components/form/file-dropzone/types.ts index 9df7d878..b976dd77 100644 --- a/community/components/form/file-dropzone/types.ts +++ b/community/components/form/file-dropzone/types.ts @@ -59,13 +59,22 @@ export class FileDropzone { } } -export interface FileDropzoneError { +export type FormControlErrors = + | { + [key: string]: SingleFileDropzoneError; + } + | null + | undefined; + +export type DropzoneValidatorError = { + errorKey: string; + value: SingleFileDropzoneError; +}; + +export type SingleFileDropzoneError = { fileName: string; - code: string; message: string; -} - -export type FileDropzoneErrors = FileDropzoneError[]; +}; export enum FileDropzoneErrorCode { FILE_TOO_LARGE = "file-too-large", @@ -76,12 +85,4 @@ export type FileInputMode = "append" | "replace"; export type ValidationState = "none" | "valid" | "invalid"; -export type DropzoneValidatorFunction = ( - maxSize: number, - acceptFileTypes: string, - file: FileDropzone, - standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -) => FileDropzoneError | undefined; - export type SizeDisplayStandard = "SI" | "IEC"; diff --git a/community/components/form/file-dropzone/utils.ts b/community/components/form/file-dropzone/utils.ts index 35c1b989..45fa29b5 100644 --- a/community/components/form/file-dropzone/utils.ts +++ b/community/components/form/file-dropzone/utils.ts @@ -1,14 +1,15 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; import { IECFileSize, SIFileSize } from "./constants"; import { + DropzoneValidatorError, FileDropzone, - FileDropzoneError, FileDropzoneErrorCode, SizeDisplayStandard, } from "./types"; export function formatBytes( bytes: number, - standard: SizeDisplayStandard + standard: SizeDisplayStandard, ): string { let kB: number = 0; let MB: number = 0; @@ -45,84 +46,149 @@ export function getDefaultHelpers( accept: string, maxSize: number, standard: SizeDisplayStandard, - translate?: (key: string, ...args: unknown[]) => string + translate?: (key: string, ...args: unknown[]) => string, ): string { if (!translate) throw new Error( - "Translate function is required to generate default helpers." + "Translate function is required to generate default helpers.", ); const textArray = []; if (accept) { textArray.push( - `${translate("file-upload.accept")} ${accept.replaceAll(",", ", ")}` + `${translate("file-upload.accept")} ${accept.replaceAll(",", ", ")}`, ); } if (maxSize) { textArray.push( - `${translate("file-upload.max-size")} ${formatBytes(maxSize, standard)}` + `${translate("file-upload.max-size")} ${formatBytes(maxSize, standard)}`, ); } return textArray.filter(Boolean).join(". "); } -export function validateFileSize( - maxSize: number, - acceptFileTypes: string, +function sanitizeFileList(files: FileDropzone[] | unknown): FileDropzone[] { + if (!Array.isArray(files)) { + return []; + } + return files.filter((file) => file instanceof FileDropzone); +} + +export const validateFileSize = + ( + maxSize: number, + standard: SizeDisplayStandard, + translate: (key: string, ...args: unknown[]) => string, + ): ValidatorFn => + (control: AbstractControl): ValidationErrors | null => { + if (maxSize === 0) { + return null; + } + const files = sanitizeFileList(control.value); + + if (!files.length) { + return null; + } + + const errors: ValidationErrors = {}; + files.forEach((file) => { + const err = validateSingleFileSize(file, maxSize, standard, translate); + if (err) { + errors[err.errorKey] = err.value; + } + }); + + if (Object.keys(errors).length) { + return errors; + } + + return null; + }; + +const validateSingleFileSize = ( file: FileDropzone, + maxSize: number, standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -): FileDropzoneError | undefined { - if (maxSize && file.size > maxSize) { - const maxSizeMB = formatBytes(maxSize, standard); + translate: (key: string, ...args: unknown[]) => string, +): DropzoneValidatorError | null => { + if (file.size > maxSize) { return { - code: FileDropzoneErrorCode.FILE_TOO_LARGE, - fileName: file.name, - message: translate( - `file-upload.size-rejected-extended`, - file.name, - maxSizeMB - ), + errorKey: FileDropzoneErrorCode.FILE_TOO_LARGE, + value: { + fileName: file.name, + message: translate( + "file-upload.size-rejected-extended", + file.name, + formatBytes(maxSize, standard), + ), + }, }; } - return undefined; -} + return null; +}; -export function validateFileType( - maxSize: number, - acceptFileTypes: string, - file: FileDropzone, - standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -): FileDropzoneError | undefined { - if (acceptFileTypes) { - const validTypes = acceptFileTypes - .split(",") - .map((type) => type.trim().toLowerCase()); - - const fileType = file.type.toLowerCase(); - const fileName = file.name.toLowerCase(); - - const matches = validTypes.some((type) => { - if (type.startsWith(".")) { - return fileName.endsWith(type); - } - if (type.endsWith("/*")) { - return fileType.startsWith(type.replace("/*", "")); +export const validateFileType = + ( + acceptFileTypes: string, + translate: (key: string, ...args: unknown[]) => string, + ): ValidatorFn => + (control: AbstractControl): ValidationErrors | null => { + const files = sanitizeFileList(control.value); + + if (!files.length) { + return null; + } + + if (acceptFileTypes) { + const errors: ValidationErrors = {}; + + files.forEach((file) => { + const err = validateSingleFileType(file, acceptFileTypes, translate); + if (err) { + errors[err.errorKey] = err.value; + } + }); + + if (Object.keys(errors).length) { + return errors; } - return fileType === type; - }); + } + return null; + }; - if (!matches) { - return { - code: FileDropzoneErrorCode.INVALID_FILE_TYPE, +const validateSingleFileType = ( + file: FileDropzone, + acceptFileTypes: string, + translate: (key: string, ...args: unknown[]) => string, +): DropzoneValidatorError | null => { + const validTypes = acceptFileTypes + .split(",") + .map((type) => type.trim().toLowerCase()); + + const fileType = file.type.toLowerCase(); + const fileName = file.name.toLowerCase(); + + const matches = validTypes.some((type) => { + if (type.startsWith(".")) { + return fileName.endsWith(type); + } + if (type.endsWith("/*")) { + return fileType.startsWith(type.replace("/*", "")); + } + return fileType === type; + }); + + if (!matches) { + return { + errorKey: FileDropzoneErrorCode.INVALID_FILE_TYPE, + value: { fileName: file.name, message: translate( "file-upload.extension-rejected-extended", file.name, - acceptFileTypes + acceptFileTypes, ), - }; - } + }, + }; } - return undefined; -} + return null; +};