From 63d3598b69ca053dbcff24f5527c67ff9ed31ff7 Mon Sep 17 00:00:00 2001 From: Artur Lang <209750178+artur-langl@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:03:25 +0200 Subject: [PATCH 1/3] fix(file-dropzone): refactor error object again and fix issue with class overriding #66 --- .../file-dropzone.component.html | 3 +- .../file-dropzone/file-dropzone.component.ts | 50 +++++++-------- .../file-dropzone/file-dropzone.stories.ts | 18 +++++- .../components/form/file-dropzone/types.ts | 21 +++++-- .../components/form/file-dropzone/utils.ts | 62 ++++++++++--------- 5 files changed, 90 insertions(+), 64 deletions(-) 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) { { + output[issue.errorKey] = issue.value; + }); + + return output; }; - validateFiles(files: FileDropzone[]): FileDropzoneErrors { - const issues: FileDropzoneErrors = []; + validateFiles(files: FileDropzone[]): ValidatorError[] { + const issues: ValidatorError[] = []; files.forEach((file) => { this.validators().forEach((validator) => { const error = validator( @@ -272,14 +279,17 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { this._translationService.translate.bind(this._translationService), ); - if (error && error.code) { + if (error?.value) { issues.push(error); file.fileStatus = "invalid"; + const message = error.value.message; file.helper = { - text: error.message, + text: message, type: "error", }; - } else if (!issues.find((issue) => issue.fileName === file.name)) { + } else if ( + !issues.find((issue) => issue.value.fileName === file.name) + ) { file.fileStatus = "valid"; file.helper = undefined; } @@ -288,17 +298,6 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { 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,17 +373,18 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { } private _currentErrorState(): string | undefined { - const errors = this._ngControl.control?.errors?.["tediFileDropzone"]; + const errors: FormControlErrors[] = + this._ngControl.control?.errors?.["tediFileDropzone"]; if (errors && !this.validateIndividually()) { - const dropzoneErrors: FileDropzoneErrors = errors; + const dropzoneErrors = errors; return dropzoneErrors.map((error) => error.message).join(" "); } return undefined; } private _getNewState(): ValidationState { - const errors: FileDropzoneErrors = + const errors: FormControlErrors[] = this._ngControl.control?.errors?.["tediFileDropzone"]; if (this._ngControl.control?.touched) { return "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..8ff09e32 100644 --- a/community/components/form/file-dropzone/file-dropzone.stories.ts +++ b/community/components/form/file-dropzone/file-dropzone.stories.ts @@ -7,7 +7,12 @@ import { 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"; /** @@ -142,7 +147,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 +157,7 @@ export const Default: Story = { form.get("files")?.value, form.controls.files, form.controls, - form.controls.files.errors + form.controls.files.errors, ); }; return { @@ -164,6 +171,11 @@ export const Default: Story = { }, }; +export const MaxFileAmount: Story = { + ...Default, + +}; + /** Replaces any same-name files, when usually it indexes them. */ export const Replace: Story = { render: (args) => ({ template: Template(args), props: args }), diff --git a/community/components/form/file-dropzone/types.ts b/community/components/form/file-dropzone/types.ts index 9df7d878..6c1bc082 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 { +// formcontrol error should be {key: {fileName: string, code: string, message: string}} + +export type FormControlErrors = { + [key: string]: SingleFileDropzoneError; +}; + +export type ValidatorError = { + 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", @@ -81,7 +90,7 @@ export type DropzoneValidatorFunction = ( acceptFileTypes: string, file: FileDropzone, standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -) => FileDropzoneError | undefined; + translate: (key: string, ...args: unknown[]) => string, +) => ValidatorError | 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..90653774 100644 --- a/community/components/form/file-dropzone/utils.ts +++ b/community/components/form/file-dropzone/utils.ts @@ -1,14 +1,14 @@ import { IECFileSize, SIFileSize } from "./constants"; import { + DropzoneValidatorFunction, 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,55 +45,58 @@ 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( +export const validateFileSize: DropzoneValidatorFunction = ( maxSize: number, acceptFileTypes: string, file: FileDropzone, standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -): FileDropzoneError | undefined { + translate: (key: string, ...args: unknown[]) => string, +) => { if (maxSize && file.size > maxSize) { const maxSizeMB = formatBytes(maxSize, standard); return { - code: FileDropzoneErrorCode.FILE_TOO_LARGE, - fileName: file.name, - message: translate( - `file-upload.size-rejected-extended`, - file.name, - maxSizeMB - ), + errorKey: "file-too-large", + value: { + code: FileDropzoneErrorCode.FILE_TOO_LARGE, + fileName: file.name, + message: translate( + `file-upload.size-rejected-extended`, + file.name, + maxSizeMB, + ), + }, }; } return undefined; -} +}; -export function validateFileType( +export const validateFileType: DropzoneValidatorFunction = ( maxSize: number, acceptFileTypes: string, file: FileDropzone, standard: SizeDisplayStandard, - translate: (key: string, ...args: unknown[]) => string -): FileDropzoneError | undefined { + translate: (key: string, ...args: unknown[]) => string, +) => { if (acceptFileTypes) { const validTypes = acceptFileTypes .split(",") @@ -114,15 +117,18 @@ export function validateFileType( if (!matches) { return { - code: FileDropzoneErrorCode.INVALID_FILE_TYPE, - fileName: file.name, - message: translate( - "file-upload.extension-rejected-extended", - file.name, - acceptFileTypes - ), + errorKey: "invalid-file-type", + value: { + code: FileDropzoneErrorCode.INVALID_FILE_TYPE, + fileName: file.name, + message: translate( + "file-upload.extension-rejected-extended", + file.name, + acceptFileTypes, + ), + }, }; } } return undefined; -} +}; From 334e42ed8448bd063385e3c85968da69feebedf6 Mon Sep 17 00:00:00 2001 From: Artur Lang <209750178+artur-langl@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:18:11 +0200 Subject: [PATCH 2/3] fix(file-dropzone): heavy refactor of validation into actual angular validators #66 --- .../file-dropzone/file-dropzone.component.ts | 91 ++--------- .../file-dropzone/file-dropzone.stories.ts | 31 ++-- .../components/form/file-dropzone/types.ts | 24 +-- .../components/form/file-dropzone/utils.ts | 151 ++++++++++++------ 4 files changed, 147 insertions(+), 150 deletions(-) diff --git a/community/components/form/file-dropzone/file-dropzone.component.ts b/community/components/form/file-dropzone/file-dropzone.component.ts index 301594a0..a888d903 100644 --- a/community/components/form/file-dropzone/file-dropzone.component.ts +++ b/community/components/form/file-dropzone/file-dropzone.component.ts @@ -14,11 +14,9 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { - AbstractControl, ControlValueAccessor, NgControl, ReactiveFormsModule, - ValidationErrors, } from "@angular/forms"; import { ClosingButtonComponent, @@ -34,14 +32,12 @@ import { } from "@tedi-design-system/angular/tedi"; import { CardComponent, CardContentComponent } from "../../cards"; import { - DropzoneValidatorFunction, FeedbackTextProps, FileDropzone, FileInputMode, FormControlErrors, SizeDisplayStandard, ValidationState, - ValidatorError, } from "./types"; import { formatBytes, @@ -146,15 +142,6 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { * @default false */ uploadFolder = input(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. @@ -225,14 +212,23 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { constructor(@Self() public _ngControl: NgControl) { this._ngControl.valueAccessor = this; - console.log("loggin"); } ngOnInit(): void { 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)) @@ -244,60 +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); - - if (!issues.length) { - return null; - } - - const output: FormControlErrors = {}; - issues.forEach((issue) => { - output[issue.errorKey] = issue.value; - }); - - return output; - }; - - validateFiles(files: FileDropzone[]): ValidatorError[] { - const issues: ValidatorError[] = []; - 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?.value) { - issues.push(error); - file.fileStatus = "invalid"; - const message = error.value.message; - file.helper = { - text: message, - type: "error", - }; - } else if ( - !issues.find((issue) => issue.value.fileName === file.name) - ) { - file.fileStatus = "valid"; - file.helper = undefined; - } - }); - }); - return issues; - } - tooltipClasses = (file: FileDropzone): string => { const classes = ["tedi-file-dropzone__tooltip"]; classes.push(`tedi-file-dropzone__tooltip--${file.helper?.type || "hint"}`); @@ -373,23 +315,22 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { } private _currentErrorState(): string | undefined { - const errors: FormControlErrors[] = - this._ngControl.control?.errors?.["tediFileDropzone"]; + const errors: FormControlErrors = this._ngControl.control?.errors; if (errors && !this.validateIndividually()) { - const dropzoneErrors = errors; + const dropzoneErrors = Object.values(errors); + return dropzoneErrors.map((error) => error.message).join(" "); } return undefined; } private _getNewState(): ValidationState { - const errors: FormControlErrors[] = - 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 8ff09e32..2e9d45ad 100644 --- a/community/components/form/file-dropzone/file-dropzone.stories.ts +++ b/community/components/form/file-dropzone/file-dropzone.stories.ts @@ -6,7 +6,6 @@ import { moduleMetadata, StoryObj, } from "@storybook/angular"; -import { validateFileSize, validateFileType } from "./utils"; import { FormControl, FormGroup, @@ -58,7 +57,6 @@ const meta: Meta = { mode: "append", name: "file-dropzone", uploadFolder: false, - validators: [validateFileSize, validateFileType], hasError: false, }, argTypes: { @@ -117,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.`, @@ -171,14 +164,28 @@ export const Default: Story = { }, }; +/** Limits the maximum amount of files to 1 */ export const MaxFileAmount: Story = { - ...Default, - -}; + 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", }, @@ -186,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 6c1bc082..b976dd77 100644 --- a/community/components/form/file-dropzone/types.ts +++ b/community/components/form/file-dropzone/types.ts @@ -59,20 +59,20 @@ export class FileDropzone { } } -// formcontrol error should be {key: {fileName: string, code: string, message: string}} - -export type FormControlErrors = { - [key: string]: SingleFileDropzoneError; -}; - -export type ValidatorError = { +export type FormControlErrors = + | { + [key: string]: SingleFileDropzoneError; + } + | null + | undefined; + +export type DropzoneValidatorError = { errorKey: string; value: SingleFileDropzoneError; }; export type SingleFileDropzoneError = { fileName: string; - code: string; message: string; }; @@ -85,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, -) => ValidatorError | 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 90653774..7aa793e8 100644 --- a/community/components/form/file-dropzone/utils.ts +++ b/community/components/form/file-dropzone/utils.ts @@ -1,6 +1,7 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; import { IECFileSize, SIFileSize } from "./constants"; import { - DropzoneValidatorFunction, + DropzoneValidatorError, FileDropzone, FileDropzoneErrorCode, SizeDisplayStandard, @@ -65,70 +66,126 @@ export function getDefaultHelpers( return textArray.filter(Boolean).join(". "); } -export const validateFileSize: DropzoneValidatorFunction = ( - 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 => { + 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, -) => { +): DropzoneValidatorError | null => { if (maxSize && file.size > maxSize) { - const maxSizeMB = formatBytes(maxSize, standard); return { - errorKey: "file-too-large", + errorKey: FileDropzoneErrorCode.FILE_TOO_LARGE, value: { - code: FileDropzoneErrorCode.FILE_TOO_LARGE, fileName: file.name, message: translate( - `file-upload.size-rejected-extended`, + "file-upload.size-rejected-extended", file.name, - maxSizeMB, + formatBytes(maxSize, standard), ), }, }; } - return undefined; + return null; }; -export const validateFileType: DropzoneValidatorFunction = ( - maxSize: number, - acceptFileTypes: string, +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 null; + }; + +const validateSingleFileType = ( file: FileDropzone, - standard: SizeDisplayStandard, + acceptFileTypes: string, translate: (key: string, ...args: unknown[]) => string, -) => { - 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("/*", "")); - } - return fileType === type; - }); +): DropzoneValidatorError | null => { + const validTypes = acceptFileTypes + .split(",") + .map((type) => type.trim().toLowerCase()); + + const fileType = file.type.toLowerCase(); + const fileName = file.name.toLowerCase(); - if (!matches) { - return { - errorKey: "invalid-file-type", - value: { - code: FileDropzoneErrorCode.INVALID_FILE_TYPE, - fileName: file.name, - message: translate( - "file-upload.extension-rejected-extended", - file.name, - acceptFileTypes, - ), - }, - }; + 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, + ), + }, + }; } - return undefined; + return null; }; From 42dcd52afc19cfa4091ffe032f3e62a2b7ba71cf Mon Sep 17 00:00:00 2001 From: Artur Lang <209750178+artur-langl@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:47:33 +0200 Subject: [PATCH 3/3] fix(file-dropzone): review request early maxSize return #66 --- community/components/form/file-dropzone/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/community/components/form/file-dropzone/utils.ts b/community/components/form/file-dropzone/utils.ts index 7aa793e8..45fa29b5 100644 --- a/community/components/form/file-dropzone/utils.ts +++ b/community/components/form/file-dropzone/utils.ts @@ -80,6 +80,9 @@ export const validateFileSize = translate: (key: string, ...args: unknown[]) => string, ): ValidatorFn => (control: AbstractControl): ValidationErrors | null => { + if (maxSize === 0) { + return null; + } const files = sanitizeFileList(control.value); if (!files.length) { @@ -107,7 +110,7 @@ const validateSingleFileSize = ( standard: SizeDisplayStandard, translate: (key: string, ...args: unknown[]) => string, ): DropzoneValidatorError | null => { - if (maxSize && file.size > maxSize) { + if (file.size > maxSize) { return { errorKey: FileDropzoneErrorCode.FILE_TOO_LARGE, value: {