- {{ 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;
+};