diff --git a/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.html b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.html index 2b8efa66d6..a2ca0b83cb 100644 --- a/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.html @@ -1,17 +1,18 @@ - -@for (file of allItems; track file) { +
+ + + +@for (file of fileDrop.value; track file) { } diff --git a/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.ts index a02c10b309..10ff7fc812 100644 --- a/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.ts @@ -1,17 +1,27 @@ -import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; import { - SkyFileDropChange, - SkyFileDropModule, - SkyFileItem, - SkyFileLink, -} from '@skyux/forms'; + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { SkyFileDropModule, SkyFileItem, SkyFileLink } from '@skyux/forms'; import { SkyStatusIndicatorModule } from '@skyux/indicators'; @Component({ standalone: true, selector: 'app-demo', templateUrl: './demo.component.html', - imports: [SkyFileDropModule, SkyStatusIndicatorModule], + imports: [ + SkyFileDropModule, + SkyStatusIndicatorModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + ], }) export class DemoComponent { protected acceptedTypes = 'image/png,image/jpeg'; @@ -22,27 +32,24 @@ export class DemoComponent { protected labelText = 'Logo image'; protected maxFileSize = 5242880; protected rejectedFiles: SkyFileItem[] = []; - protected required = true; protected stacked = 'true'; - #filesToUpload: SkyFileItem[] = []; - #linksToUpload: SkyFileLink[] = []; + protected fileDrop = new FormControl< + (SkyFileItem | SkyFileLink)[] | null | undefined + >(undefined, Validators.required); + protected formGroup: FormGroup = inject(FormBuilder).group({ + fileDrop: this.fileDrop, + }); protected deleteFile(file: SkyFileItem | SkyFileLink): void { - this.#removeFromArray(this.allItems, file); - this.#removeFromArray(this.#filesToUpload, file); - this.#removeFromArray(this.#linksToUpload, file); - } - - protected onFilesChanged(change: SkyFileDropChange): void { - this.#filesToUpload = this.#filesToUpload.concat(change.files); - this.rejectedFiles = change.rejectedFiles; - this.allItems = this.allItems.concat(change.files); - } + const index = this.fileDrop.value?.indexOf(file); - protected onLinkChanged(change: SkyFileLink): void { - this.#linksToUpload = this.#linksToUpload.concat(change); - this.allItems = this.allItems.concat(change); + if (index !== undefined && index !== -1) { + this.fileDrop.value?.splice(index, 1); + } + if (this.fileDrop.value?.length === 0) { + this.fileDrop.setValue(null); + } } protected validateFile(file: SkyFileItem): string | undefined { @@ -50,17 +57,4 @@ export class DemoComponent { ? 'Upload a file that does not begin with the letter "a"' : undefined; } - - #removeFromArray( - items: (SkyFileItem | SkyFileLink)[], - obj: SkyFileItem | SkyFileLink, - ): void { - if (items) { - const index = items.indexOf(obj); - - if (index !== -1) { - items.splice(index, 1); - } - } - } } diff --git a/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.html b/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.html index d89e5228ad..bab307ca44 100644 --- a/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.html +++ b/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.html @@ -1,35 +1,31 @@ - -@for (file of allItems; track file) { - -} -
- + +@for (file of attachment.value; track file) { + +} + +Values +
Touched: {{ attachment.touched }}
+
Dirty: {{ attachment.dirty }}
+
Value: {{ attachment.value | json }}
+ + + diff --git a/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.ts b/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.ts index 3245c96bd7..f8e24dfdeb 100644 --- a/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.ts +++ b/apps/playground/src/app/components/forms/file-attachment/file-attachment.component.ts @@ -1,17 +1,11 @@ import { Component, inject } from '@angular/core'; import { - AbstractControl, FormBuilder, FormControl, FormGroup, Validators, } from '@angular/forms'; -import { - SkyFileAttachmentChange, - SkyFileDropChange, - SkyFileItem, - SkyFileLink, -} from '@skyux/forms'; +import { SkyFileItem, SkyFileLink } from '@skyux/forms'; @Component({ selector: 'app-file-attachment-demo', @@ -22,28 +16,15 @@ export class FileAttachmentComponent { public allItems: (SkyFileItem | SkyFileLink)[]; - public filesToUpload: SkyFileItem[]; - - public linksToUpload: SkyFileLink[]; - public maxFileSize = 4000000; public minFileSize = 300000; - public rejectedFiles: SkyFileItem[]; - - protected attachment: FormControl; + protected attachment: FormControl; protected formGroup: FormGroup; protected required = true; - get #reactiveFile(): AbstractControl | null { - return this.formGroup.get('attachment'); - } - constructor() { - this.filesToUpload = []; - this.rejectedFiles = []; this.allItems = []; - this.linksToUpload = []; this.attachment = new FormControl(undefined, Validators.required); this.formGroup = inject(FormBuilder).group({ attachment: this.attachment, @@ -51,20 +32,13 @@ export class FileAttachmentComponent { } public deleteFile(file: SkyFileItem | SkyFileLink): void { - this.#removeFromArray(this.allItems, file); - this.#removeFromArray(this.filesToUpload, file); - this.#removeFromArray(this.linksToUpload, file); - } - - public filesUpdated(result: SkyFileDropChange): void { - this.filesToUpload = this.filesToUpload.concat(result.files); - this.rejectedFiles = result.rejectedFiles; - this.allItems = this.allItems.concat(result.files); - } - - public linkAdded(result: SkyFileLink): void { - this.linksToUpload = this.linksToUpload.concat(result); - this.allItems = this.allItems.concat(result); + const index = this.attachment.value.indexOf(file); + if (index !== -1) { + this.attachment.value.splice(index, 1); + } + if (this.attachment.value.length === 0) { + this.attachment.setValue(null); + } } public validateFile(file: SkyFileItem): string { @@ -73,23 +47,28 @@ export class FileAttachmentComponent { } } - protected onFileChange(result: SkyFileAttachmentChange): void { - const file = result.file; - - if (file && file.errorType) { - this.#reactiveFile?.setValue(undefined); - } else { - this.#reactiveFile?.setValue(file); - } + public markTouched(): void { + this.attachment.markAsTouched(); } - #removeFromArray(items: any[], obj: SkyFileItem | SkyFileLink): void { - if (items) { - const index = items.indexOf(obj); - - if (index !== -1) { - items.splice(index, 1); - } - } + public setFiles(): void { + this.attachment.setValue([ + { + file: new File([], 'foo.bar', { type: 'image/png' }), + url: 'foo.bar.bar', + }, + { + url: 'foo.bar.bar', + }, + { + file: undefined, + url: 'foo.foo', + }, + + { + file: new File([], 'foo.bar', { type: 'image/png' }), + url: 'foo.bar.bar', + }, + ]); } } diff --git a/apps/playground/src/app/components/forms/file-attachment/file-attachment.module.ts b/apps/playground/src/app/components/forms/file-attachment/file-attachment.module.ts index bf034078c1..f63d201864 100644 --- a/apps/playground/src/app/components/forms/file-attachment/file-attachment.module.ts +++ b/apps/playground/src/app/components/forms/file-attachment/file-attachment.module.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { SkyFileAttachmentsModule } from '@skyux/forms'; @@ -11,6 +12,7 @@ import { FileAttachmentComponent } from './file-attachment.component'; @NgModule({ imports: [ + CommonModule, FileAttachmentRoutingModule, FormsModule, ReactiveFormsModule, diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.html b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.html index 76055adb1e..c3f6fcd713 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.html +++ b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.html @@ -167,7 +167,7 @@ { if (fileItem.file.size < minFileSize) { fileItem.errorType = 'minFileSize'; fileItem.errorParam = minFileSize.toString(); @@ -45,7 +41,7 @@ export class SkyFileAttachmentService { } else { fileResults.push(fileItem); } - } + }); return fileResults; } diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.html b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.html index 7502bcde3d..93a7d13688 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.html +++ b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.html @@ -9,11 +9,11 @@ {{ labelText }} - @if (required) { + @if (isRequired) { {{ 'skyux_file_attachment_required' | skyLibResources }} @@ -198,7 +198,9 @@ @for (rejectedFile of rejectedFiles; track rejectedFile) {
diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.spec.ts b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.spec.ts index 56cc720f74..474d244c78 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.spec.ts +++ b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.spec.ts @@ -1,5 +1,6 @@ import { Component, DebugElement } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { Validators } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; @@ -7,6 +8,7 @@ import { SkyIdService, SkyLiveAnnouncerService } from '@skyux/core'; import { SkyHelpTestingController, SkyHelpTestingModule, + provideSkyFileReaderTesting, } from '@skyux/core/testing'; import { SkyFileItem } from '../shared/file-item'; @@ -15,6 +17,7 @@ import { SkyFileDropChange } from './file-drop-change'; import { SkyFileDropComponent } from './file-drop.component'; import { SkyFileDropModule } from './file-drop.module'; import { SkyFileLink } from './file-link'; +import { ReactiveFileDropTestComponent } from './fixtures/reactive-file-drop.component.fixture'; describe('File drop component', () => { /** Simple test component with tabIndex */ @@ -322,7 +325,11 @@ describe('File drop component', () => { expect(dragOverPropStopped).toBe(true); } - function triggerDrop(files: any, dropDebugEl: DebugElement): void { + function triggerDrop( + fixture: ComponentFixture, + files: any, + dropDebugEl: DebugElement, + ): void { let dropPropStopped = false; let dropPreventDefault = false; const fileLength = files ? files.length : 0; @@ -504,7 +511,7 @@ describe('File drop component', () => { testClick(false); }); - it('should load and emit files on file change event', () => { + it('should load and emit files on file change event', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -512,6 +519,7 @@ describe('File drop component', () => { ); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.files.length).toBe(2); expect(filesChangedActual?.files[0].url).toBe('url'); @@ -526,7 +534,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(2); }); - it('should load and emit files on file change event when file reader has an error and aborts', () => { + it('should load and emit files on file change event when file reader has an error and aborts', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -550,19 +558,16 @@ describe('File drop component', () => { }, ]); - fixture.detectChanges(); - fileReaderSpy.abortCallbacks[0](); - fileReaderSpy.loadCallbacks[1]({ target: { result: 'anotherUrl', }, }); - fileReaderSpy.errorCallbacks[2](); fixture.detectChanges(); + await fixture.whenStable(); expect(filesChangedActual?.files.length).toBe(1); expect(filesChangedActual?.files[0].url).toBe('anotherUrl'); @@ -601,7 +606,7 @@ describe('File drop component', () => { expect(inputEl.nativeElement.getAttribute('accept')).toBe('image/png'); }); - it('should allow the user to specify a min file size', () => { + it('should allow the user to specify a min file size', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -612,6 +617,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('foo.txt'); @@ -628,7 +634,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(1); }); - it('should respect a default min file size of 0', () => { + it('should respect a default min file size of 0', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -636,6 +642,7 @@ describe('File drop component', () => { ); const spy = setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); expect(filesChangedActual?.files.length).toBe(2); @@ -659,6 +666,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('foo.txt'); @@ -682,6 +690,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); expect(filesChangedActual?.files.length).toBe(2); @@ -698,7 +707,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(2); }); - it('should allow the user to specify a max file size', () => { + it('should allow the user to specify a max file size', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -709,6 +718,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('woo.txt'); @@ -725,7 +735,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(1); }); - it('should respect a default max file size of 500000', () => { + it('should respect a default max file size of 500000', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -733,6 +743,7 @@ describe('File drop component', () => { ); const spy = setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); expect(filesChangedActual?.files.length).toBe(2); @@ -756,6 +767,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('woo.txt'); @@ -779,6 +791,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); expect(filesChangedActual?.files.length).toBe(2); @@ -795,7 +808,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(2); }); - it('should allow the user to specify a validation function', () => { + it('should allow the user to specify a validation function', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -817,6 +830,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('woo.txt'); @@ -833,7 +847,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(1); }); - it('should allow the user to specify accepted types', () => { + it('should allow the user to specify accepted types', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -845,6 +859,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(1); expect(filesChangedActual?.rejectedFiles[0].file.name).toBe('woo.txt'); @@ -861,7 +876,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(1); }); - it('should reject a file with no type when accepted types are defined', () => { + it('should reject a file with no type when accepted types are defined', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -885,6 +900,7 @@ describe('File drop component', () => { ]; setupStandardFileChangeEvent(files); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(2); expect(filesChangedActual?.rejectedFiles[1].file.name).toBe('woo.txt'); @@ -900,7 +916,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(0); }); - it('should allow the user to specify accepted type with wildcards', () => { + it('should allow the user to specify accepted type with wildcards', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -912,6 +928,7 @@ describe('File drop component', () => { fixture.detectChanges(); setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); @@ -928,7 +945,7 @@ describe('File drop component', () => { expect(liveAnnouncerSpy.calls.count()).toBe(2); }); - it('should load files and set classes on drag and drop', () => { + it('should load files and set classes on drag and drop', async () => { let filesChangedActual: SkyFileDropChange | undefined; componentInstance.filesChanged.subscribe( @@ -965,7 +982,7 @@ describe('File drop component', () => { validateDropClasses(true, false, dropElWrapper); - triggerDrop(files, dropDebugEl); + triggerDrop(fixture, files, dropDebugEl); validateDropClasses(false, false, dropElWrapper); @@ -976,6 +993,7 @@ describe('File drop component', () => { }); fixture.detectChanges(); + await fixture.whenStable(); expect(filesChangedActual?.rejectedFiles.length).toBe(0); expect(filesChangedActual?.files.length).toBe(1); @@ -1043,7 +1061,7 @@ describe('File drop component', () => { triggerDragOver(undefined, dropDebugEl); validateDropClasses(true, false, dropElWrapper); - triggerDrop(invalidFiles, dropDebugEl); + triggerDrop(fixture, invalidFiles, dropDebugEl); validateDropClasses(false, false, dropElWrapper); }, ); @@ -1071,7 +1089,7 @@ describe('File drop component', () => { triggerDragEnter('sky-drop', dropDebugEl); triggerDragOver(files, dropDebugEl); - triggerDrop(files, dropDebugEl); + triggerDrop(fixture, files, dropDebugEl); expect(fileReaderSpy.loadCallbacks.length).toBe(2); }); @@ -1098,7 +1116,7 @@ describe('File drop component', () => { triggerDragEnter('sky-drop', dropDebugEl); triggerDragOver(files, dropDebugEl); - triggerDrop(files, dropDebugEl); + triggerDrop(fixture, files, dropDebugEl); expect(fileReaderSpy.loadCallbacks.length).toBe(0); }); @@ -1123,7 +1141,7 @@ describe('File drop component', () => { triggerDragEnter('sky-drop', dropDebugEl); triggerDragOver(files, dropDebugEl); - triggerDrop(files, dropDebugEl); + triggerDrop(fixture, files, dropDebugEl); expect(fileReaderSpy.loadCallbacks.length).toBe(0); }); @@ -1361,3 +1379,215 @@ describe('File drop component', () => { helpController.expectCurrentHelpKey('helpKey.html'); }); }); + +describe('File drop reactive component', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SkyFileDropModule], + providers: [provideSkyFileReaderTesting()], + }); + fixture = TestBed.createComponent(ReactiveFileDropTestComponent); + fixture.detectChanges(); + }); + + it('should mark control as touched on file `drop` event', () => { + expect(fixture.componentInstance.fileDrop.touched).toBeFalse(); + const dropEl = fixture.debugElement.query(By.css('.sky-file-drop')); + + const dropEvent = { + dataTransfer: {}, + stopPropagation: function (): void {}, + preventDefault: function (): void {}, + }; + + dropEl.triggerEventHandler('drop', dropEvent); + expect(fixture.componentInstance.fileDrop.touched).toBeTrue(); + }); + + it('should mark control as touched on file drop clicked', () => { + expect(fixture.componentInstance.fileDrop.touched).toBeFalse(); + const dropEl = fixture.nativeElement.querySelector('.sky-file-drop'); + + dropEl.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.fileDrop.touched).toBeTrue(); + }); + + it('should mark control as touched on link added', () => { + expect(fixture.componentInstance.fileDrop.touched).toBeFalse(); + const linkButton = fixture.debugElement.query( + By.css('.sky-file-drop-link button'), + ); + const linkEl = fixture.debugElement.query( + By.css('.sky-file-drop-link input'), + ); + + linkEl.triggerEventHandler('input', { target: { value: 'link.url' } }); + fixture.detectChanges(); + + linkButton.nativeElement.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.fileDrop.touched).toBeTrue(); + }); + + it('should mark control as touched on link blur', () => { + expect(fixture.componentInstance.fileDrop.touched).toBeFalse(); + const linkEl = fixture.nativeElement.querySelector( + '.sky-file-drop-link input', + ); + + SkyAppTestUtility.fireDomEvent(linkEl, 'blur'); + fixture.detectChanges(); + + expect(fixture.componentInstance.fileDrop.touched).toBeTrue(); + }); + + it('should set file drop to required using form control', () => { + fixture.componentInstance.labelText = 'File Drop'; + fixture.componentInstance.fileDrop.addValidators(Validators.required); + fixture.detectChanges(); + + const label = fixture.nativeElement.querySelector( + '.sky-file-drop-label-text', + ); + + expect(label.classList.contains('sky-control-label-required')).toBeTrue(); + }); + + it('should show required error', () => { + fixture.componentInstance.labelText = 'testing'; + fixture.detectChanges(); + const linkInput = fixture.nativeElement.querySelector( + '.sky-file-drop-link input', + ); + SkyAppTestUtility.fireDomEvent(linkInput, 'blur'); + fixture.detectChanges(); + + const requiredError = fixture.nativeElement.querySelector( + "sky-form-error[errorName='required']", + ); + expect(requiredError).toBeVisible(); + }); + + describe('form control value', () => { + it('should set value', async () => { + const file: SkyFileItem = { + file: new File([], 'foo.bar', { type: 'image/png' }), + url: 'foo.bar.bar', + }; + + const link: SkyFileLink = { + url: 'foo.foo', + }; + + fixture.componentInstance.fileDrop.setValue([file, link]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value.length).toBe(2); + }); + + it('should not add invalid files', async () => { + fixture.componentInstance.fileDrop.setValue([ + { + file: new File([], 'foo.bar', { type: 'image/png' }), + url: 'foo.bar.bar', + }, + { + file: undefined, + url: 'foo.bar.bar', + }, + { + url: 'foo.bar.bar', + }, + + { + url: undefined, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value.length).toBe(2); + }); + + it('should handle no valid files uploaded', async () => { + fixture.componentInstance.fileDrop.setValue('anything'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value).toBe(null); + + fixture.componentInstance.fileDrop.setValue([ + { + file: undefined, + url: 'foo.bar.bar', + }, + { + url: undefined, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value).toBe(null); + }); + + it('should handle rejecting all files', async () => { + fixture.componentInstance.acceptedTypes = 'image/png'; + fixture.detectChanges(); + + fixture.componentInstance.fileDrop.setValue([ + { + file: new File([], 'foo.foo', { type: 'abcd/png' }), + url: 'foo.bar.bar', + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value).toBe(null); + }); + + it('should handle accepting some files', async () => { + fixture.componentInstance.acceptedTypes = 'image/png'; + fixture.detectChanges(); + + fixture.componentInstance.fileDrop.setValue([ + { + url: 'foo.bar.bar', + }, + { + file: new File([], 'foo.foo', { type: 'abcd/png' }), + url: 'foo.bar.bar', + }, + ]); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value.length).toBe(1); + }); + + it('should not set the form control value before handle files is complete', async () => { + fixture.componentInstance.fileDrop.setValue([ + { + file: new File([], 'foo.bar', { type: 'image/png' }), + url: 'foo.bar.bar', + }, + { + file: undefined, + url: 'foo.bar.bar', + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.fileDrop.value.length).toBe(1); + }); + }); +}); diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.ts b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.ts index 84b4200f36..47c3e4a465 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.ts +++ b/libs/components/forms/src/lib/modules/file-attachment/file-drop/file-drop.component.ts @@ -12,8 +12,17 @@ import { booleanAttribute, inject, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { SkyIdModule, SkyLiveAnnouncerService } from '@skyux/core'; +import { + ControlValueAccessor, + FormsModule, + NgControl, + Validators, +} from '@angular/forms'; +import { + SkyFileReaderService, + SkyIdModule, + SkyLiveAnnouncerService, +} from '@skyux/core'; import { SkyIdService } from '@skyux/core'; import { SkyHelpInlineModule } from '@skyux/help-inline'; import { SkyLibResourcesService } from '@skyux/i18n'; @@ -71,7 +80,7 @@ const MIN_FILE_SIZE_DEFAULT = 0; SkyThemeModule, ], }) -export class SkyFileDropComponent implements OnDestroy { +export class SkyFileDropComponent implements OnDestroy, ControlValueAccessor { /** * Fires when users add or remove files. */ @@ -254,22 +263,84 @@ export class SkyFileDropComponent implements OnDestroy { #_minFileSize = MIN_FILE_SIZE_DEFAULT; + #notifyTouched: (() => void) | undefined; + #notifyChange: + | ((_: (SkyFileItem | SkyFileLink)[] | undefined | null) => void) + | undefined; + #_uploadedFiles: (SkyFileItem | SkyFileLink)[] = []; + readonly #fileAttachmentService = inject(SkyFileAttachmentService); + readonly #fileReaderSvc = inject(SkyFileReaderService); readonly #liveAnnouncerSvc = inject(SkyLiveAnnouncerService); readonly #resourcesSvc = inject(SkyLibResourcesService); readonly #idSvc = inject(SkyIdService); protected errorId = this.#idSvc.generateId(); + + protected ngControl = inject(NgControl, { optional: true }); + protected rejectedFiles: SkyFileItem[] = []; + constructor() { + if (this.ngControl) { + this.ngControl.valueAccessor = this; + } + } + public ngOnDestroy(): void { this.filesChanged.complete(); this.linkChanged.complete(); this.linkInputBlur.complete(); } + public writeValue(value: unknown): void { + if (Array.isArray(value)) { + const linkUploads: SkyFileLink[] = []; + const fileUploads: SkyFileItem[] = []; + + value.forEach((file) => { + if ('url' in file && file.url !== undefined) { + if (!('file' in file)) { + linkUploads.push(file); + } else if ('file' in file && file.file !== undefined) { + fileUploads.push(file); + } + } + }); + + if (!(linkUploads.length > 0) && !(fileUploads.length > 0)) { + this.#notifyChange?.(null); + } else { + this.#_uploadedFiles = []; + + if (linkUploads.length > 0) { + linkUploads.forEach((file) => { + this.uploadLink(file); + }); + } + if (fileUploads.length > 0) { + // this prevents FormControl from setting an invalid value before the async + // processes in #handleFile is complete + this.#notifyChange?.(null); + this.#handleFiles(fileUploads); + } + } + } else { + this.#notifyChange?.(null); + } + } + + public registerOnChange(fn: any): void { + this.#notifyChange = fn; + } + + public registerOnTouched(fn: () => void): void { + this.#notifyTouched = fn; + } + public dropClicked(): void { if (!this.noClick && this.inputEl) { + this.#notifyTouched?.(); this.inputEl.nativeElement.click(); } } @@ -329,6 +400,8 @@ export class SkyFileDropComponent implements OnDestroy { dropEvent.stopPropagation(); dropEvent.preventDefault(); + this.#notifyTouched?.(); + this.#enterEventTarget = undefined; this.rejectedOver = false; this.acceptedOver = false; @@ -363,18 +436,33 @@ export class SkyFileDropComponent implements OnDestroy { public addLink(event: Event): void { event.preventDefault(); - this.linkChanged.emit({ url: this.linkUrl } as SkyFileLink); + this.uploadLink({ url: this.linkUrl } as SkyFileLink); + this.linkUrl = undefined; + this.#notifyTouched?.(); + } + + protected uploadLink(file: SkyFileLink): void { + this.linkChanged.emit(file); + this.#_uploadedFiles?.push(file); + this.#notifyChange?.(this.#_uploadedFiles); this.#announceState( 'skyux_file_attachment_file_upload_link_added', - this.linkUrl, + file.url, ); - this.linkUrl = undefined; } public onLinkBlur(): void { + this.#notifyTouched?.(); this.linkInputBlur.emit(); } + protected get isRequired(): boolean { + return ( + this.required || + (this.ngControl?.control?.hasValidator(Validators.required) ?? false) + ); + } + #announceState(resourceString: string, ...args: any[]): void { this.#resourcesSvc .getString(resourceString, ...args) @@ -408,20 +496,22 @@ export class SkyFileDropComponent implements OnDestroy { totalFiles: number, ): void { rejectedFileArray.push(file); + this.#notifyChange?.( + this.#_uploadedFiles.length > 0 ? this.#_uploadedFiles : null, + ); this.#emitFileChangeEvent(totalFiles, rejectedFileArray, validFileArray); } - #loadFile( + async #loadFile( fileDrop: SkyFileDropComponent, file: SkyFileItem, validFileArray: SkyFileItem[], rejectedFileArray: SkyFileItem[], totalFiles: number, - ): void { - const reader = new FileReader(); + ): Promise { + try { + file.url = await this.#fileReaderSvc.readAsDataURL(file.file); - reader.addEventListener('load', (event: any) => { - file.url = event.target.result; validFileArray.push(file); fileDrop.#emitFileChangeEvent( totalFiles, @@ -432,34 +522,35 @@ export class SkyFileDropComponent implements OnDestroy { 'skyux_file_attachment_file_upload_file_added', file.file.name, ); - }); - - reader.addEventListener('error', () => { + this.#_uploadedFiles?.push(file); + this.#notifyChange?.(this.#_uploadedFiles); + } catch { fileDrop.#filesRejected( file, validFileArray, rejectedFileArray, totalFiles, ); - }); - - reader.addEventListener('abort', () => { - fileDrop.#filesRejected( - file, - validFileArray, - rejectedFileArray, - totalFiles, - ); - }); - - reader.readAsDataURL(file.file); + } } - #handleFiles(files?: FileList | null): void { - if (files) { + #handleFiles(fileList?: FileList | null | SkyFileItem[]): void { + if (fileList) { const validFileArray: SkyFileItem[] = []; const rejectedFileArray: SkyFileItem[] = []; - const totalFiles = files.length; + const totalFiles = fileList.length; + + let files: SkyFileItem[] = []; + + if ('item' in fileList) { + for (let index = 0; index < fileList.length; index++) { + files.push({ + file: fileList.item(index), + } as SkyFileItem); + } + } else { + files = fileList; + } const processedFiles = this.#fileAttachmentService.checkFiles( files, @@ -478,7 +569,7 @@ export class SkyFileDropComponent implements OnDestroy { totalFiles, ); } else { - this.#loadFile( + void this.#loadFile( this, file, validFileArray, diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.html b/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.html new file mode 100644 index 0000000000..666dcf4c48 --- /dev/null +++ b/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.html @@ -0,0 +1,12 @@ +
+ + + +{{fileDrop.value | json}} @for (file of fileDrop.value; track file) { + +} diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.ts b/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.ts new file mode 100644 index 0000000000..eef8088cef --- /dev/null +++ b/libs/components/forms/src/lib/modules/file-attachment/file-drop/fixtures/reactive-file-drop.component.fixture.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; + +import { SkyFileItem } from '../../shared/file-item'; +import { SkyFileDropModule } from '../file-drop.module'; +import { SkyFileLink } from '../file-link'; + +@Component({ + imports: [SkyFileDropModule, FormsModule, ReactiveFormsModule, CommonModule], + selector: 'sky-file-drop-reactive-test', + standalone: true, + templateUrl: './reactive-file-drop.component.fixture.html', +}) +export class ReactiveFileDropTestComponent { + public fileDrop: FormControl = new FormControl( + undefined, + Validators.required, + ); + public formGroup: FormGroup = inject(FormBuilder).group({ + fileDrop: this.fileDrop, + }); + public labelText: string | undefined; + public acceptedTypes: string | undefined; + + public deleteFile(file: SkyFileItem | SkyFileLink): void { + const index = this.fileDrop.value.indexOf(file); + if (index !== -1) { + this.fileDrop.value?.splice(index, 1); + } + if (this.fileDrop.value.length === 0) { + this.fileDrop.setValue(null); + } + } +}