Skip to content

fix(file-upload): synced file type validation (#DS-3331) #848

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/components-dev/file-upload/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ export class DevFileUploadStateAndStyle {
<file-upload-single-validation-reactive-forms-overview-example />
<hr />
<file-upload-single-with-signal-example />
<hr />
<file-upload-single-accept-validation-example />
<hr />
<file-upload-multiple-accept-validation-example />
`,
host: {
class: 'layout-column'
Expand Down
104 changes: 103 additions & 1 deletion packages/components/core/forms/validators.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FormControl } from '@angular/forms';
import { FileValidators } from '@koobiq/components/core';
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
import { KbqFileItem } from '@koobiq/components/file-upload';
import { PasswordValidators } from './validators';

Expand Down Expand Up @@ -128,6 +128,108 @@ describe('Validators', () => {
control.setValue(null);
expect(control.errors).toBeNull();
});

describe(FileValidators.isCorrectExtension.name, () => {
it('should return null if the value contains correct extensions', () => {
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(['.txt'])
);
const kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.txt', { type: 'text/plain' })
};

control.setValue(kbqFileItem);
expect(control.errors).toBeNull();
});

it('should return null if the value contains correct extensions with deep level > 2', () => {
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(['.tmp.txt'])
);
const kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.tmp.txt', { type: 'text/plain' })
};

control.setValue(kbqFileItem);
expect(control.errors).toBeNull();
});

it('should return an error if the value contains wrong extensions', () => {
const accept: KbqFileTypeSpecifier = ['.pdf'];
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(accept)
);
let kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.txt', { type: 'text/plain' })
};

control.setValue(kbqFileItem);
expect(control.errors).toEqual({
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
});

kbqFileItem = { file: new File(['content'], 'test.pdf.txt', { type: 'text/plain' }) };

control.setValue(kbqFileItem);
expect(control.errors).toEqual({
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
});
});
it('should return an error if the value contains wrong extensions with deep level > 2', () => {
const accept: KbqFileTypeSpecifier = ['.tmp.pdf'];
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(accept)
);

let kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.txt', { type: 'text/plain' })
};

control.setValue(kbqFileItem);
expect(control.errors).toEqual({
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
});

kbqFileItem = { file: new File(['content'], 'test.tmp.pdf.txt', { type: 'text/plain' }) };

control.setValue(kbqFileItem);
expect(control.errors).toEqual({
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
});
});
it('should return null if file type is correct', () => {
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(['text/plain'])
);
const kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.txt', { type: 'text/plain' })
};

control.setValue(kbqFileItem);
expect(control.errors).toBeNull();
});

it('should return null if file type is wrong', () => {
const accept: KbqFileTypeSpecifier = ['text/plain'];
const control = new FormControl<KbqFileItem | File | null>(
null,
FileValidators.isCorrectExtension(accept)
);
const kbqFileItem: KbqFileItem = {
file: new File(['content'], 'test.txt', { type: 'text/css' })
};

control.setValue(kbqFileItem);
expect(control.errors).toEqual({
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
});
});
});
});
});
});
30 changes: 30 additions & 0 deletions packages/components/core/forms/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,34 @@ export class FileValidators {
return null;
};
}

/**
* Validator that checks whether file's name or MIME type
* matches one of the accepted extensions or MIME types.
*
* @param accept - Array of allowed file extensions or MIME types.
* @returns ValidatorFn that returns validation error if file type is not accepted, or null otherwise.
*/
static isCorrectExtension(accept: (`.${string}` | `${string}/${string}`)[]): ValidatorFn {
return (control: AbstractControl<{ file: File } | null>): ValidationErrors | null => {
if (!accept?.length || !control.value) return null;
const { name, type } = control.value.file;

for (const acceptedExtensionOrMimeType of accept) {
const typeAsRegExp = new RegExp(`${acceptedExtensionOrMimeType}$`);

if (!typeAsRegExp.test(name) && !typeAsRegExp.test(type)) {
return { fileExtensionMismatch: { expected: accept, actual: name } };
}
}

return null;
};
}
}

/**
* Type helper describing accepted file types, referring to:
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#unique_file_type_specifiers
*/
export type KbqFileTypeSpecifier = Parameters<typeof FileValidators.isCorrectExtension>[0];
12 changes: 12 additions & 0 deletions packages/components/file-upload/examples.file-upload.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,15 @@ The examples use [FileValidators](https://github.com/koobiq/angular-components/b
- **Multiple Files**: An example of uploading multiple files with Reactive Forms and built-in validation.

<!-- example(file-upload-multiple-default-validation-reactive-forms-overview) -->

#### File type or extension validation

The examples use [FileValidators](https://github.com/koobiq/angular-components/blob/main/packages/components/core/forms/validators.ts).

- **Single file**: example of uploading a single file using `Reactive Forms` with validation.

<!-- example(file-upload-single-accept-validation) -->

- **Multiple files**: Example of uploading multiple files using `Reactive Forms` with validation.

<!-- example(file-upload-multiple-accept-validation) -->
12 changes: 12 additions & 0 deletions packages/components/file-upload/examples.file-upload.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@
- **Несколько файлов**: Пример загрузки нескольких файлов с использованием `Reactive Forms` и встроенной валидации.

<!-- example(file-upload-multiple-default-validation-reactive-forms-overview) -->

#### Валидация типа или расширения файла

В примерах используется [FileValidators](https://github.com/koobiq/angular-components/blob/main/packages/components/core/forms/validators.ts).

- **Один файл**: Пример загрузки одного файла с применением `Reactive Forms` для проверки данных.

<!-- example(file-upload-single-accept-validation) -->

- **Несколько файлов**: Пример загрузки нескольких файлов с использованием `Reactive Forms` и валидации.

<!-- example(file-upload-multiple-accept-validation) -->
1 change: 1 addition & 0 deletions packages/components/file-upload/file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type KbqFileValidatorFn = (file: File) => string | null;
/* Object for labels customization inside file upload component */
export const KBQ_FILE_UPLOAD_CONFIGURATION = new InjectionToken<KbqInputFileLabel>('KbqFileUploadConfiguration');

/** @deprecated use `FileValidators.isCorrectExtension` instead. Will be removed in next major release. */
export const isCorrectExtension = (file: File, accept?: string[]): boolean => {
if (!accept?.length) return true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { KbqListSelection } from '@koobiq/components/list';
import { ProgressSpinnerMode } from '@koobiq/components/progress-spinner';
import { BehaviorSubject } from 'rxjs';
import {
isCorrectExtension,
KBQ_FILE_UPLOAD_CONFIGURATION,
KbqFile,
KbqFileItem,
Expand Down Expand Up @@ -324,14 +323,12 @@ export class KbqMultipleFileUploadComponent
return [];
}

return Array.from(files)
.filter((file) => isCorrectExtension(file, this.accept))
.map((file: File) => ({
file,
hasError: this.validateFile(file),
loading: new BehaviorSubject<boolean>(false),
progress: new BehaviorSubject<number>(0)
}));
return Array.from(files).map((file: File) => ({
file,
hasError: this.validateFile(file),
loading: new BehaviorSubject<boolean>(false),
progress: new BehaviorSubject<number>(0)
}));
}

private validateFile(file: File): boolean | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { ProgressSpinnerMode } from '@koobiq/components/progress-spinner';
import { BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import {
isCorrectExtension,
KBQ_FILE_UPLOAD_CONFIGURATION,
KbqFile,
KbqFileItem,
Expand Down Expand Up @@ -243,7 +242,7 @@ export class KbqSingleFileUploadComponent
return;
}

if (files?.length && isCorrectExtension(files[0], this.accept)) {
if (files?.length) {
this.file = this.mapToFileItem(files[0]);
this.fileChange.emit(this.file);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormArray, FormControl, ReactiveFormsModule } from '@angular/forms';
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
import { KbqFileItem, KbqFileUploadModule } from '@koobiq/components/file-upload';
import { KbqFormFieldModule } from '@koobiq/components/form-field';
import { KbqIconModule } from '@koobiq/components/icon';

/**
* @title File upload multiple accept validation
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
selector: 'file-upload-multiple-accept-validation-example',
template: `
<kbq-file-upload (fileRemoved)="onFileRemoved($event)" (filesAdded)="onFilesAdded($event)" multiple>
<ng-template #kbqFileIcon let-file>
@if (!file.hasError) {
<i kbq-icon="kbq-file-o_16"></i>
}
@if (file.hasError) {
<i kbq-icon="kbq-exclamation-triangle_16"></i>
}
</ng-template>

<kbq-hint>Files with .txt extension are allowed</kbq-hint>

@for (control of fileList.controls; track $index) {
<kbq-hint color="error">
@if (control.hasError('fileExtensionMismatch')) {
{{ control.value?.file?.name }} - {{ fileExtensionMismatchErrorMessage }}
}
</kbq-hint>
}
</kbq-file-upload>
`,
imports: [
ReactiveFormsModule,
KbqFileUploadModule,
KbqFormFieldModule,
KbqIconModule
]
})
export class FileUploadMultipleAcceptValidationExample {
protected accept: KbqFileTypeSpecifier = ['.txt'];
protected fileExtensionMismatchErrorMessage = 'Provide valid extension';

protected readonly fileList = new FormArray<FormControl<KbqFileItem | null>>([]);

constructor() {
this.fileList.statusChanges.subscribe(() => {
this.fileList.controls.forEach((control) => {
if (control?.value && 'hasError' in control.value) {
control.value.hasError = control.invalid;
}
});
});
}

onFileRemoved([
_,
index
]: [
KbqFileItem,
number
]): void {
this.fileList.removeAt(index);
}

onFilesAdded($event: KbqFileItem[]): void {
for (const fileItem of $event.slice()) {
this.fileList.push(new FormControl(fileItem, FileValidators.isCorrectExtension(this.accept)));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
import { KbqFileUploadModule } from '@koobiq/components/file-upload';
import { KbqFormFieldModule } from '@koobiq/components/form-field';
import { KbqIconModule } from '@koobiq/components/icon';

/**
* @title File upload single accept validation
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
selector: 'file-upload-single-accept-validation-example',
template: `
<form [formGroup]="formGroup">
<kbq-file-upload class="layout-margin-bottom-s" [accept]="accept" formControlName="fileControl">
@if (!formGroup.get('fileControl')?.errors) {
<i kbq-icon="kbq-file-o_16"></i>
}
@if (formGroup.get('fileControl')?.errors) {
<i kbq-icon="kbq-exclamation-triangle_16"></i>
}

<kbq-hint>File with .txt extension is allowed</kbq-hint>

@if (formGroup.get('fileControl')?.hasError('fileExtensionMismatch')) {
<kbq-hint color="error">Provide valid extension</kbq-hint>
}
</kbq-file-upload>
</form>
`,
imports: [
ReactiveFormsModule,
KbqFileUploadModule,
KbqFormFieldModule,
KbqIconModule
]
})
export class FileUploadSingleAcceptValidationExample {
protected accept: KbqFileTypeSpecifier = ['.txt'];

protected readonly formGroup = new FormGroup({
fileControl: new FormControl(null, FileValidators.isCorrectExtension(this.accept))
});
}
Loading