Skip to content

Commit 7fa8ab6

Browse files
authored
fix(file-upload): synced file type validation (#DS-3331) (#848)
1 parent 38fef80 commit 7fa8ab6

File tree

13 files changed

+302
-14
lines changed

13 files changed

+302
-14
lines changed

packages/components-dev/file-upload/module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ export class DevFileUploadStateAndStyle {
354354
<file-upload-single-validation-reactive-forms-overview-example />
355355
<hr />
356356
<file-upload-single-with-signal-example />
357+
<hr />
358+
<file-upload-single-accept-validation-example />
359+
<hr />
360+
<file-upload-multiple-accept-validation-example />
357361
`,
358362
host: {
359363
class: 'layout-column'

packages/components/core/forms/validators.spec.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FormControl } from '@angular/forms';
2-
import { FileValidators } from '@koobiq/components/core';
2+
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
33
import { KbqFileItem } from '@koobiq/components/file-upload';
44
import { PasswordValidators } from './validators';
55

@@ -128,6 +128,108 @@ describe('Validators', () => {
128128
control.setValue(null);
129129
expect(control.errors).toBeNull();
130130
});
131+
132+
describe(FileValidators.isCorrectExtension.name, () => {
133+
it('should return null if the value contains correct extensions', () => {
134+
const control = new FormControl<KbqFileItem | File | null>(
135+
null,
136+
FileValidators.isCorrectExtension(['.txt'])
137+
);
138+
const kbqFileItem: KbqFileItem = {
139+
file: new File(['content'], 'test.txt', { type: 'text/plain' })
140+
};
141+
142+
control.setValue(kbqFileItem);
143+
expect(control.errors).toBeNull();
144+
});
145+
146+
it('should return null if the value contains correct extensions with deep level > 2', () => {
147+
const control = new FormControl<KbqFileItem | File | null>(
148+
null,
149+
FileValidators.isCorrectExtension(['.tmp.txt'])
150+
);
151+
const kbqFileItem: KbqFileItem = {
152+
file: new File(['content'], 'test.tmp.txt', { type: 'text/plain' })
153+
};
154+
155+
control.setValue(kbqFileItem);
156+
expect(control.errors).toBeNull();
157+
});
158+
159+
it('should return an error if the value contains wrong extensions', () => {
160+
const accept: KbqFileTypeSpecifier = ['.pdf'];
161+
const control = new FormControl<KbqFileItem | File | null>(
162+
null,
163+
FileValidators.isCorrectExtension(accept)
164+
);
165+
let kbqFileItem: KbqFileItem = {
166+
file: new File(['content'], 'test.txt', { type: 'text/plain' })
167+
};
168+
169+
control.setValue(kbqFileItem);
170+
expect(control.errors).toEqual({
171+
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
172+
});
173+
174+
kbqFileItem = { file: new File(['content'], 'test.pdf.txt', { type: 'text/plain' }) };
175+
176+
control.setValue(kbqFileItem);
177+
expect(control.errors).toEqual({
178+
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
179+
});
180+
});
181+
it('should return an error if the value contains wrong extensions with deep level > 2', () => {
182+
const accept: KbqFileTypeSpecifier = ['.tmp.pdf'];
183+
const control = new FormControl<KbqFileItem | File | null>(
184+
null,
185+
FileValidators.isCorrectExtension(accept)
186+
);
187+
188+
let kbqFileItem: KbqFileItem = {
189+
file: new File(['content'], 'test.txt', { type: 'text/plain' })
190+
};
191+
192+
control.setValue(kbqFileItem);
193+
expect(control.errors).toEqual({
194+
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
195+
});
196+
197+
kbqFileItem = { file: new File(['content'], 'test.tmp.pdf.txt', { type: 'text/plain' }) };
198+
199+
control.setValue(kbqFileItem);
200+
expect(control.errors).toEqual({
201+
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
202+
});
203+
});
204+
it('should return null if file type is correct', () => {
205+
const control = new FormControl<KbqFileItem | File | null>(
206+
null,
207+
FileValidators.isCorrectExtension(['text/plain'])
208+
);
209+
const kbqFileItem: KbqFileItem = {
210+
file: new File(['content'], 'test.txt', { type: 'text/plain' })
211+
};
212+
213+
control.setValue(kbqFileItem);
214+
expect(control.errors).toBeNull();
215+
});
216+
217+
it('should return null if file type is wrong', () => {
218+
const accept: KbqFileTypeSpecifier = ['text/plain'];
219+
const control = new FormControl<KbqFileItem | File | null>(
220+
null,
221+
FileValidators.isCorrectExtension(accept)
222+
);
223+
const kbqFileItem: KbqFileItem = {
224+
file: new File(['content'], 'test.txt', { type: 'text/css' })
225+
};
226+
227+
control.setValue(kbqFileItem);
228+
expect(control.errors).toEqual({
229+
fileExtensionMismatch: { expected: accept, actual: kbqFileItem.file.name }
230+
});
231+
});
232+
});
131233
});
132234
});
133235
});

packages/components/core/forms/validators.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,34 @@ export class FileValidators {
179179
return null;
180180
};
181181
}
182+
183+
/**
184+
* Validator that checks whether file's name or MIME type
185+
* matches one of the accepted extensions or MIME types.
186+
*
187+
* @param accept - Array of allowed file extensions or MIME types.
188+
* @returns ValidatorFn that returns validation error if file type is not accepted, or null otherwise.
189+
*/
190+
static isCorrectExtension(accept: (`.${string}` | `${string}/${string}`)[]): ValidatorFn {
191+
return (control: AbstractControl<{ file: File } | null>): ValidationErrors | null => {
192+
if (!accept?.length || !control.value) return null;
193+
const { name, type } = control.value.file;
194+
195+
for (const acceptedExtensionOrMimeType of accept) {
196+
const typeAsRegExp = new RegExp(`${acceptedExtensionOrMimeType}$`);
197+
198+
if (!typeAsRegExp.test(name) && !typeAsRegExp.test(type)) {
199+
return { fileExtensionMismatch: { expected: accept, actual: name } };
200+
}
201+
}
202+
203+
return null;
204+
};
205+
}
182206
}
207+
208+
/**
209+
* Type helper describing accepted file types, referring to:
210+
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#unique_file_type_specifiers
211+
*/
212+
export type KbqFileTypeSpecifier = Parameters<typeof FileValidators.isCorrectExtension>[0];

packages/components/file-upload/examples.file-upload.en.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,15 @@ The examples use [FileValidators](https://github.com/koobiq/angular-components/b
4040
- **Multiple Files**: An example of uploading multiple files with Reactive Forms and built-in validation.
4141

4242
<!-- example(file-upload-multiple-default-validation-reactive-forms-overview) -->
43+
44+
#### File type or extension validation
45+
46+
The examples use [FileValidators](https://github.com/koobiq/angular-components/blob/main/packages/components/core/forms/validators.ts).
47+
48+
- **Single file**: example of uploading a single file using `Reactive Forms` with validation.
49+
50+
<!-- example(file-upload-single-accept-validation) -->
51+
52+
- **Multiple files**: Example of uploading multiple files using `Reactive Forms` with validation.
53+
54+
<!-- example(file-upload-multiple-accept-validation) -->

packages/components/file-upload/examples.file-upload.ru.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,15 @@
4242
- **Несколько файлов**: Пример загрузки нескольких файлов с использованием `Reactive Forms` и встроенной валидации.
4343

4444
<!-- example(file-upload-multiple-default-validation-reactive-forms-overview) -->
45+
46+
#### Валидация типа или расширения файла
47+
48+
В примерах используется [FileValidators](https://github.com/koobiq/angular-components/blob/main/packages/components/core/forms/validators.ts).
49+
50+
- **Один файл**: Пример загрузки одного файла с применением `Reactive Forms` для проверки данных.
51+
52+
<!-- example(file-upload-single-accept-validation) -->
53+
54+
- **Несколько файлов**: Пример загрузки нескольких файлов с использованием `Reactive Forms` и валидации.
55+
56+
<!-- example(file-upload-multiple-accept-validation) -->

packages/components/file-upload/file-upload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type KbqFileValidatorFn = (file: File) => string | null;
3939
/* Object for labels customization inside file upload component */
4040
export const KBQ_FILE_UPLOAD_CONFIGURATION = new InjectionToken<KbqInputFileLabel>('KbqFileUploadConfiguration');
4141

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

packages/components/file-upload/multiple-file-upload.component.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { KbqListSelection } from '@koobiq/components/list';
2323
import { ProgressSpinnerMode } from '@koobiq/components/progress-spinner';
2424
import { BehaviorSubject } from 'rxjs';
2525
import {
26-
isCorrectExtension,
2726
KBQ_FILE_UPLOAD_CONFIGURATION,
2827
KbqFile,
2928
KbqFileItem,
@@ -324,14 +323,12 @@ export class KbqMultipleFileUploadComponent
324323
return [];
325324
}
326325

327-
return Array.from(files)
328-
.filter((file) => isCorrectExtension(file, this.accept))
329-
.map((file: File) => ({
330-
file,
331-
hasError: this.validateFile(file),
332-
loading: new BehaviorSubject<boolean>(false),
333-
progress: new BehaviorSubject<number>(0)
334-
}));
326+
return Array.from(files).map((file: File) => ({
327+
file,
328+
hasError: this.validateFile(file),
329+
loading: new BehaviorSubject<boolean>(false),
330+
progress: new BehaviorSubject<number>(0)
331+
}));
335332
}
336333

337334
private validateFile(file: File): boolean | undefined {

packages/components/file-upload/single-file-upload.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { ProgressSpinnerMode } from '@koobiq/components/progress-spinner';
2222
import { BehaviorSubject } from 'rxjs';
2323
import { distinctUntilChanged } from 'rxjs/operators';
2424
import {
25-
isCorrectExtension,
2625
KBQ_FILE_UPLOAD_CONFIGURATION,
2726
KbqFile,
2827
KbqFileItem,
@@ -243,7 +242,7 @@ export class KbqSingleFileUploadComponent
243242
return;
244243
}
245244

246-
if (files?.length && isCorrectExtension(files[0], this.accept)) {
245+
if (files?.length) {
247246
this.file = this.mapToFileItem(files[0]);
248247
this.fileChange.emit(this.file);
249248
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ChangeDetectionStrategy, Component } from '@angular/core';
2+
import { FormArray, FormControl, ReactiveFormsModule } from '@angular/forms';
3+
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
4+
import { KbqFileItem, KbqFileUploadModule } from '@koobiq/components/file-upload';
5+
import { KbqFormFieldModule } from '@koobiq/components/form-field';
6+
import { KbqIconModule } from '@koobiq/components/icon';
7+
8+
/**
9+
* @title File upload multiple accept validation
10+
*/
11+
@Component({
12+
changeDetection: ChangeDetectionStrategy.OnPush,
13+
standalone: true,
14+
selector: 'file-upload-multiple-accept-validation-example',
15+
template: `
16+
<kbq-file-upload (fileRemoved)="onFileRemoved($event)" (filesAdded)="onFilesAdded($event)" multiple>
17+
<ng-template #kbqFileIcon let-file>
18+
@if (!file.hasError) {
19+
<i kbq-icon="kbq-file-o_16"></i>
20+
}
21+
@if (file.hasError) {
22+
<i kbq-icon="kbq-exclamation-triangle_16"></i>
23+
}
24+
</ng-template>
25+
26+
<kbq-hint>Files with .txt extension are allowed</kbq-hint>
27+
28+
@for (control of fileList.controls; track $index) {
29+
<kbq-hint color="error">
30+
@if (control.hasError('fileExtensionMismatch')) {
31+
{{ control.value?.file?.name }} - {{ fileExtensionMismatchErrorMessage }}
32+
}
33+
</kbq-hint>
34+
}
35+
</kbq-file-upload>
36+
`,
37+
imports: [
38+
ReactiveFormsModule,
39+
KbqFileUploadModule,
40+
KbqFormFieldModule,
41+
KbqIconModule
42+
]
43+
})
44+
export class FileUploadMultipleAcceptValidationExample {
45+
protected accept: KbqFileTypeSpecifier = ['.txt'];
46+
protected fileExtensionMismatchErrorMessage = 'Provide valid extension';
47+
48+
protected readonly fileList = new FormArray<FormControl<KbqFileItem | null>>([]);
49+
50+
constructor() {
51+
this.fileList.statusChanges.subscribe(() => {
52+
this.fileList.controls.forEach((control) => {
53+
if (control?.value && 'hasError' in control.value) {
54+
control.value.hasError = control.invalid;
55+
}
56+
});
57+
});
58+
}
59+
60+
onFileRemoved([
61+
_,
62+
index
63+
]: [
64+
KbqFileItem,
65+
number
66+
]): void {
67+
this.fileList.removeAt(index);
68+
}
69+
70+
onFilesAdded($event: KbqFileItem[]): void {
71+
for (const fileItem of $event.slice()) {
72+
this.fileList.push(new FormControl(fileItem, FileValidators.isCorrectExtension(this.accept)));
73+
}
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ChangeDetectionStrategy, Component } from '@angular/core';
2+
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
3+
import { FileValidators, KbqFileTypeSpecifier } from '@koobiq/components/core';
4+
import { KbqFileUploadModule } from '@koobiq/components/file-upload';
5+
import { KbqFormFieldModule } from '@koobiq/components/form-field';
6+
import { KbqIconModule } from '@koobiq/components/icon';
7+
8+
/**
9+
* @title File upload single accept validation
10+
*/
11+
@Component({
12+
changeDetection: ChangeDetectionStrategy.OnPush,
13+
standalone: true,
14+
selector: 'file-upload-single-accept-validation-example',
15+
template: `
16+
<form [formGroup]="formGroup">
17+
<kbq-file-upload class="layout-margin-bottom-s" [accept]="accept" formControlName="fileControl">
18+
@if (!formGroup.get('fileControl')?.errors) {
19+
<i kbq-icon="kbq-file-o_16"></i>
20+
}
21+
@if (formGroup.get('fileControl')?.errors) {
22+
<i kbq-icon="kbq-exclamation-triangle_16"></i>
23+
}
24+
25+
<kbq-hint>File with .txt extension is allowed</kbq-hint>
26+
27+
@if (formGroup.get('fileControl')?.hasError('fileExtensionMismatch')) {
28+
<kbq-hint color="error">Provide valid extension</kbq-hint>
29+
}
30+
</kbq-file-upload>
31+
</form>
32+
`,
33+
imports: [
34+
ReactiveFormsModule,
35+
KbqFileUploadModule,
36+
KbqFormFieldModule,
37+
KbqIconModule
38+
]
39+
})
40+
export class FileUploadSingleAcceptValidationExample {
41+
protected accept: KbqFileTypeSpecifier = ['.txt'];
42+
43+
protected readonly formGroup = new FormGroup({
44+
fileControl: new FormControl(null, FileValidators.isCorrectExtension(this.accept))
45+
});
46+
}

0 commit comments

Comments
 (0)