Skip to content

Commit f4962ac

Browse files
authored
feat(file-upload): duplicate handling and file select strategy (#DS-4617) (#1898)
1 parent 88086f7 commit f4962ac

10 files changed

Lines changed: 177 additions & 3 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,11 @@ The user can drag files onto the page, and at that moment a full-screen upload o
6565
### Upload area
6666

6767
The drag-and-drop area can be not only the entire screen or the File Upload component itself within a form, but also a separate part of the screen.
68+
69+
### Adding files
70+
71+
By default, every new selection is added to the files you already have — this makes it easy to build up a list over several picks.
72+
73+
If you want a new selection to replace the previous one instead — the way a regular system file dialog works — use `addStrategy="replace"`.
74+
75+
<!-- example(file-upload-multiple-add-strategy) -->

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,11 @@
6767
Областью для перетаскивания может быть не только весь экран, сам компонент File Upload в форме, но и отдельная часть экрана
6868

6969
<!-- example(file-upload-local-dropzone) -->
70+
71+
### Добавление файлов
72+
73+
По умолчанию каждый новый выбор файлов добавляется к уже загруженным — так удобно собирать список за несколько заходов.
74+
75+
Если нужно, чтобы новый выбор заменял предыдущий — как в обычном системном диалоге выбора файлов, — используйте `addStrategy="replace"`.
76+
77+
<!-- example(file-upload-multiple-add-strategy) -->

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
import { Observable, timer } from 'rxjs';
2828
import { map } from 'rxjs/operators';
2929
import { KbqDropzoneData, KbqFullScreenDropzoneService, KbqLocalDropzone } from './dropzone';
30-
import { KbqFileItem } from './file-upload';
30+
import { KbqFileItem, KbqFileUploadAddStrategy, KbqFileUploadAddStrategyValues } from './file-upload';
3131
import { KbqFileUploadModule } from './file-upload.module';
3232
import { KbqInputFileMultipleLabel, KbqMultipleFileUploadComponent } from './multiple-file-upload.component';
3333
import { KbqFileDropDirective } from './primitives/file-drop';
@@ -329,6 +329,69 @@ describe(KbqMultipleFileUploadComponent.name, () => {
329329
});
330330
});
331331

332+
describe('with addStrategy input', () => {
333+
// Same object reused across dispatches so name/size/type/lastModified are guaranteed
334+
// identical, instead of relying on two `new File(...)` calls landing in the same millisecond.
335+
const duplicateFile: Partial<File> = { name: FILE_NAME, size: 4, type: '', lastModified: 1700000000000 };
336+
const otherFile: Partial<File> = { name: 'other.file', size: 4, type: '', lastModified: 1700000000000 };
337+
338+
// The native input is inside the `@if (!files.length) {...} @else {...}` branch of the
339+
// template, so it gets destroyed/recreated when the file count crosses zero — must be
340+
// re-queried before every dispatch rather than cached once.
341+
const dispatchChange = (file: Partial<File>) => {
342+
dispatchEvent(component.fileUpload().input!.nativeElement, getMockedChangeEvent(file));
343+
fixture.detectChanges();
344+
};
345+
346+
it('should default to concat strategy', () => {
347+
expect(component.fileUpload().addStrategy()).toBe(KbqFileUploadAddStrategy.Concat);
348+
});
349+
350+
describe('concat strategy (default)', () => {
351+
it('should skip a file that duplicates one already in the list', () => {
352+
dispatchChange(duplicateFile);
353+
dispatchChange(duplicateFile);
354+
355+
expect(component.files).toHaveLength(1);
356+
});
357+
358+
it('should still add files that are genuinely different', () => {
359+
dispatchChange(duplicateFile);
360+
dispatchChange(otherFile);
361+
362+
expect(component.files).toHaveLength(2);
363+
});
364+
365+
it('should emit filesAdded with an empty array for a skipped duplicate', () => {
366+
const filesAddedSpy = jest.fn();
367+
const subscription = component.fileUpload().filesAdded.subscribe(filesAddedSpy);
368+
369+
dispatchChange(duplicateFile);
370+
dispatchChange(duplicateFile);
371+
372+
subscription.unsubscribe();
373+
374+
expect(filesAddedSpy).toHaveBeenCalledTimes(2);
375+
expect(filesAddedSpy.mock.calls[1][0]).toHaveLength(0);
376+
});
377+
});
378+
379+
describe('replace strategy', () => {
380+
beforeEach(() => {
381+
component.addStrategy.set(KbqFileUploadAddStrategy.Replace);
382+
fixture.detectChanges();
383+
});
384+
385+
it('should replace the list instead of appending on a new selection', () => {
386+
dispatchChange(duplicateFile);
387+
dispatchChange(otherFile);
388+
389+
expect(component.files).toHaveLength(1);
390+
expect(component.files[0].file.name).toBe('other.file');
391+
});
392+
});
393+
});
394+
332395
describe('with ControlValueAccessor', () => {
333396
let fixture: ComponentFixture<ControlValueAccessorMultipleFileUpload>;
334397
let component: ControlValueAccessorMultipleFileUpload;
@@ -1764,6 +1827,7 @@ class ControlValueAccessorSingleFileUpload {
17641827
[disabled]="disabled"
17651828
[fullScreenDropZone]="fullScreenDropZone()"
17661829
[localeConfig]="localeConfig()"
1830+
[addStrategy]="addStrategy()"
17671831
(fileQueueChanged)="onChange($event)"
17681832
/>
17691833
</div>
@@ -1777,6 +1841,7 @@ class BasicMultipleFileUpload {
17771841
disabled: boolean;
17781842
files: KbqFileItem[];
17791843
fullScreenDropZone = signal<KbqDropzoneData | boolean | undefined>(undefined);
1844+
addStrategy = signal<KbqFileUploadAddStrategyValues>(KbqFileUploadAddStrategy.Concat);
17801845

17811846
localeConfig = signal<Partial<KbqBaseFileUploadLocaleConfig>>({});
17821847

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ export enum KbqFileUploadAllowedType {
4242
/** Allowed upload modes for the upload component. */
4343
export type KbqFileUploadAllowedTypeValues = KbqEnumValues<KbqFileUploadAllowedType>;
4444

45+
/** Strategy for merging newly selected/dropped files into the existing file list. */
46+
export enum KbqFileUploadAddStrategy {
47+
/** Accumulates files across interactions, skipping files that duplicate ones already present. */
48+
Concat = 'concat',
49+
/** Discards the previous selection, mirroring native `<input multiple>` behavior. */
50+
Replace = 'replace'
51+
}
52+
53+
/** Allowed add strategies for the upload component. */
54+
export type KbqFileUploadAddStrategyValues = KbqEnumValues<KbqFileUploadAddStrategy>;
55+
4556
/** @docs-private */
4657
export type KbqFileUploadCaptionContext = {
4758
captionText: string;

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
KBQ_FILE_UPLOAD_CONFIGURATION,
4242
KbqFile,
4343
KbqFileItem,
44+
KbqFileUploadAddStrategy,
45+
KbqFileUploadAddStrategyValues,
4446
KbqFileUploadAllowedType,
4547
KbqFileUploadBase,
4648
KbqFileUploadCaptionContext
@@ -132,6 +134,13 @@ export class KbqMultipleFileUploadComponent
132134
* Provide configuration object to enable, or undefined to disable.
133135
*/
134136
fullScreenDropZone = input<KbqDropzoneData | boolean>();
137+
/**
138+
* Controls how newly selected/dropped files are merged into the existing list.
139+
* `concat` accumulates files across interactions, skipping files that duplicate ones already present.
140+
* `replace` discards the previous selection, mirroring native input file multiple behavior.
141+
* @default concat
142+
*/
143+
readonly addStrategy = input<KbqFileUploadAddStrategyValues>(KbqFileUploadAddStrategy.Concat);
135144

136145
/** Optional configuration to override default labels with localized text.*/
137146
readonly localeConfig = input<Partial<KbqMultipleFileUploadLocaleConfig>>();
@@ -391,7 +400,12 @@ export class KbqMultipleFileUploadComponent
391400
}
392401

393402
private onFileAdded(filesToAdd: KbqFileItem[]) {
394-
this.fileList.addArray(filesToAdd);
403+
if (this.addStrategy() === KbqFileUploadAddStrategy.Replace) {
404+
this.fileList.replace(filesToAdd);
405+
} else {
406+
filesToAdd = filesToAdd.filter((fileToAdd) => !this.isDuplicate(fileToAdd.file, this.files));
407+
this.fileList.addArray(filesToAdd);
408+
}
395409

396410
this.cvaOnChange(this.files);
397411

@@ -400,4 +414,14 @@ export class KbqMultipleFileUploadComponent
400414
this.fileQueueChanged.emit(this.files);
401415
this.onTouched();
402416
}
417+
418+
private isDuplicate(candidate: File, existing: KbqFileItem[]): boolean {
419+
return existing.some(
420+
({ file }) =>
421+
file.name === candidate.name &&
422+
file.size === candidate.size &&
423+
file.lastModified === candidate.lastModified &&
424+
file.type === candidate.type
425+
);
426+
}
403427
}

packages/components/file-upload/primitives/file-picker.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,21 @@ describe('KbqFileList', () => {
437437
});
438438
});
439439

440+
describe('replace', () => {
441+
it('should replace the list with the specified items and emit event', () => {
442+
directive.addArray([file1, file3]);
443+
directive.replace([file1, file2, file3]);
444+
expect(directive.list()).toEqual([file1, file2, file3]);
445+
expect(component.itemsAddedEvent).toEqual([file1, file2, file3]);
446+
});
447+
448+
it('should emit itemsAdded event with added items', () => {
449+
directive.replace([file1, file2]);
450+
fixture.detectChanges();
451+
expect(component.itemsAddedEvent).toEqual([file1, file2]);
452+
});
453+
});
454+
440455
describe('remove', () => {
441456
beforeEach(() => {
442457
directive.list.set([]);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export class KbqFileList<T> {
131131
this.itemsAdded.emit(items);
132132
}
133133

134+
/** Replaces the current list with the specified items and emits event. */
135+
replace(items: T[]): void {
136+
this.update(() => items);
137+
this.itemsAdded.emit(items);
138+
}
139+
134140
/** Removes the first occurrence of the specified item. Returns removed items and emits event. */
135141
remove(item: T): T[] {
136142
const removed: T[] = [];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ChangeDetectionStrategy, Component } from '@angular/core';
2+
import { KbqMultipleFileUploadComponent } from '@koobiq/components/file-upload';
3+
import { KbqIconModule } from '@koobiq/components/icon';
4+
5+
/**
6+
* @title File-upload multiple add strategy
7+
*/
8+
@Component({
9+
selector: 'file-upload-multiple-add-strategy-example',
10+
imports: [
11+
KbqIconModule,
12+
KbqMultipleFileUploadComponent
13+
],
14+
template: `
15+
<kbq-multiple-file-upload [addStrategy]="'replace'">
16+
<ng-template #kbqFileIcon>
17+
<i kbq-icon="kbq-file-text-o_16"></i>
18+
</ng-template>
19+
</kbq-multiple-file-upload>
20+
`,
21+
changeDetection: ChangeDetectionStrategy.OnPush
22+
})
23+
export class FileUploadMultipleAddStrategyExample {}

packages/docs-examples/components/file-upload/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FileUploadDropzoneExample } from './file-upload-dropzone/file-upload-dr
66
import { FileUploadIndeterminateLoadingOverviewExample } from './file-upload-indeterminate-loading-overview/file-upload-indeterminate-loading-overview-example';
77
import { FileUploadLocalDropzoneExample } from './file-upload-local-dropzone/file-upload-local-dropzone-example';
88
import { FileUploadMultipleAcceptValidationExample } from './file-upload-multiple-accept-validation/file-upload-multiple-accept-validation-example';
9+
import { FileUploadMultipleAddStrategyExample } from './file-upload-multiple-add-strategy/file-upload-multiple-add-strategy-example';
910
import { FileUploadMultipleCompactOverviewExample } from './file-upload-multiple-compact-overview/file-upload-multiple-compact-overview-example';
1011
import { FileUploadMultipleCustomTextOverviewExample } from './file-upload-multiple-custom-text-overview/file-upload-multiple-custom-text-overview-example';
1112
import { FileUploadMultipleDefaultOverviewExample } from './file-upload-multiple-default-overview/file-upload-multiple-default-overview-example';
@@ -36,6 +37,7 @@ export {
3637
FileUploadIndeterminateLoadingOverviewExample,
3738
FileUploadLocalDropzoneExample,
3839
FileUploadMultipleAcceptValidationExample,
40+
FileUploadMultipleAddStrategyExample,
3941
FileUploadMultipleCompactOverviewExample,
4042
FileUploadMultipleCustomTextOverviewExample,
4143
FileUploadMultipleDefaultOverviewExample,
@@ -74,6 +76,7 @@ const EXAMPLES = [
7476
FileUploadMultipleDefaultValidationReactiveFormsOverviewExample,
7577
FileUploadMultipleDefaultOverviewExample,
7678
FileUploadMultipleCustomTextOverviewExample,
79+
FileUploadMultipleAddStrategyExample,
7780
FileUploadIndeterminateLoadingOverviewExample,
7881
FileUploadMultipleCompactOverviewExample,
7982
FileUploadSingleRequiredReactiveValidationExample,

tools/public_api_guard/components/file-upload.api.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export class KbqFileList<T> {
151151
readonly list: _angular_core.ModelSignal<T[]>;
152152
remove(item: T): T[];
153153
removeAt(index: number): T[];
154+
replace(items: T[]): void;
154155
// (undocumented)
155156
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<KbqFileList<any>, "[kbqFileList]", ["kbqFileList"], { "list": { "alias": "list"; "required": false; "isSignal": true; }; }, { "list": "listChange"; "itemRemoved": "itemRemoved"; "itemsAdded": "itemsAdded"; }, never, never, true, never>;
156157
// (undocumented)
@@ -177,6 +178,15 @@ export class KbqFileLoader {
177178
static ɵfac: _angular_core.ɵɵFactoryDeclaration<KbqFileLoader, never>;
178179
}
179180

181+
// @public
182+
export enum KbqFileUploadAddStrategy {
183+
Concat = "concat",
184+
Replace = "replace"
185+
}
186+
187+
// @public
188+
export type KbqFileUploadAddStrategyValues = KbqEnumValues<KbqFileUploadAddStrategy>;
189+
180190
// @public
181191
export enum KbqFileUploadAllowedType {
182192
// (undocumented)
@@ -300,6 +310,7 @@ export class KbqMultipleFileUploadComponent extends KbqFileUploadBase implements
300310
constructor();
301311
readonly accept: _angular_core.InputSignal<string[] | undefined>;
302312
get acceptedFiles(): string;
313+
readonly addStrategy: _angular_core.InputSignal<"concat" | "replace">;
303314
allowed: _angular_core.InputSignal<"file" | "folder" | "mixed">;
304315
protected readonly captionContext: _angular_core.Signal<KbqFileUploadCaptionContext>;
305316
protected get captionTextWhenSelected(): string;
@@ -347,7 +358,7 @@ export class KbqMultipleFileUploadComponent extends KbqFileUploadBase implements
347358
readonly size: _angular_core.InputSignal<"compact" | "default">;
348359
writeValue(files: FileList | KbqFileItem[] | null): void;
349360
// (undocumented)
350-
static ɵcmp: _angular_core.ɵɵComponentDeclaration<KbqMultipleFileUploadComponent, "kbq-multiple-file-upload,kbq-file-upload[multiple]", never, { "progressMode": { "alias": "progressMode"; "required": false; "isSignal": true; }; "accept": { "alias": "accept"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "inputId": { "alias": "inputId"; "required": false; "isSignal": true; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "files": { "alias": "files"; "required": false; }; "allowed": { "alias": "allowed"; "required": false; "isSignal": true; }; "fullScreenDropZone": { "alias": "fullScreenDropZone"; "required": false; "isSignal": true; }; "localeConfig": { "alias": "localeConfig"; "required": false; "isSignal": true; }; }, { "filesChange": "filesChange"; "fileQueueChanged": "fileQueueChanged"; "filesAdded": "filesAdded"; "fileRemoved": "fileRemoved"; }, ["customFileIcon", "hint"], ["kbq-hint"], true, [{ directive: typeof KbqFileUploadContext; inputs: { "id": "id"; "disabled": "disabled"; }; outputs: {}; }, { directive: typeof KbqFileList; inputs: {}; outputs: { "itemsAdded": "itemsAdded"; "itemRemoved": "itemRemoved"; }; }]>;
361+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<KbqMultipleFileUploadComponent, "kbq-multiple-file-upload,kbq-file-upload[multiple]", never, { "progressMode": { "alias": "progressMode"; "required": false; "isSignal": true; }; "accept": { "alias": "accept"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "inputId": { "alias": "inputId"; "required": false; "isSignal": true; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "files": { "alias": "files"; "required": false; }; "allowed": { "alias": "allowed"; "required": false; "isSignal": true; }; "fullScreenDropZone": { "alias": "fullScreenDropZone"; "required": false; "isSignal": true; }; "addStrategy": { "alias": "addStrategy"; "required": false; "isSignal": true; }; "localeConfig": { "alias": "localeConfig"; "required": false; "isSignal": true; }; }, { "filesChange": "filesChange"; "fileQueueChanged": "fileQueueChanged"; "filesAdded": "filesAdded"; "fileRemoved": "fileRemoved"; }, ["customFileIcon", "hint"], ["kbq-hint"], true, [{ directive: typeof KbqFileUploadContext; inputs: { "id": "id"; "disabled": "disabled"; }; outputs: {}; }, { directive: typeof KbqFileList; inputs: {}; outputs: { "itemsAdded": "itemsAdded"; "itemRemoved": "itemRemoved"; }; }]>;
351362
// (undocumented)
352363
static ɵfac: _angular_core.ɵɵFactoryDeclaration<KbqMultipleFileUploadComponent, never>;
353364
}

0 commit comments

Comments
 (0)