diff --git a/libs/components/core/src/index.ts b/libs/components/core/src/index.ts index 85abe612bf..90b6424399 100644 --- a/libs/components/core/src/index.ts +++ b/libs/components/core/src/index.ts @@ -37,6 +37,8 @@ export { SkyDynamicComponentService, } from './lib/modules/dynamic-component/dynamic-component.service'; +export { SkyFileReaderService } from './lib/modules/file-reader/file-reader.service'; + export { SkyAppFormat } from './lib/modules/format/app-format'; export { SkyHelpGlobalOptions } from './lib/modules/help/help-global-options'; diff --git a/libs/components/core/src/lib/modules/file-reader/file-reader.service.spec.ts b/libs/components/core/src/lib/modules/file-reader/file-reader.service.spec.ts new file mode 100644 index 0000000000..9db8870f98 --- /dev/null +++ b/libs/components/core/src/lib/modules/file-reader/file-reader.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; + +import { SkyFileReaderService } from './file-reader.service'; + +describe('file-reader.service', () => { + function setupTest(options?: { + readFileWithStatus: 'abort' | 'error' | 'load'; + }): { fileReaderSvc: SkyFileReaderService } { + const fileReaderSvc = TestBed.inject(SkyFileReaderService); + + const { readFileWithStatus } = options || { readFileWithStatus: 'load' }; + + spyOn(window, 'FileReader').and.returnValue({ + addEventListener: (eventName: string, cb: (data?: unknown) => void) => { + if (eventName === 'load' && readFileWithStatus === 'load') { + cb({ target: { result: 'data:MOCK' } }); + } else { + if (eventName === readFileWithStatus) { + cb(); + } + } + }, + readAsDataURL: () => { + /* */ + }, + } as unknown as FileReader); + + return { fileReaderSvc }; + } + + it('should read a file as data URL', async () => { + const { fileReaderSvc } = setupTest(); + + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + + const result = await fileReaderSvc.readAsDataURL(file); + + expect(result).toEqual('data:MOCK'); + }); + + it('should reject on error', async () => { + const { fileReaderSvc } = setupTest({ readFileWithStatus: 'error' }); + + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + + await expectAsync(fileReaderSvc.readAsDataURL(file)).toBeRejectedWith(file); + }); + + it('should reject on abort', async () => { + const { fileReaderSvc } = setupTest({ readFileWithStatus: 'abort' }); + + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + + await expectAsync(fileReaderSvc.readAsDataURL(file)).toBeRejectedWith(file); + }); +}); diff --git a/libs/components/core/src/lib/modules/file-reader/file-reader.service.ts b/libs/components/core/src/lib/modules/file-reader/file-reader.service.ts new file mode 100644 index 0000000000..9bad6053ec --- /dev/null +++ b/libs/components/core/src/lib/modules/file-reader/file-reader.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; + +/** + * Wraps the FileReader API so it can be mocked in tests. + * @internal + */ +@Injectable({ + providedIn: 'root', +}) +export class SkyFileReaderService { + public async readAsDataURL(file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener('load', (event) => { + resolve(event.target?.result as string); + }); + + reader.addEventListener('error', () => { + reject(file); + }); + + reader.addEventListener('abort', () => { + reject(file); + }); + + reader.readAsDataURL(file); + }); + } +} diff --git a/libs/components/core/testing/src/modules/file-reader/file-reader-testing.service.ts b/libs/components/core/testing/src/modules/file-reader/file-reader-testing.service.ts new file mode 100644 index 0000000000..409ac0f0d6 --- /dev/null +++ b/libs/components/core/testing/src/modules/file-reader/file-reader-testing.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { SkyFileReaderService } from '@skyux/core'; + +/** + * @internal + */ +@Injectable() +export class SkyFileReaderTestingService extends SkyFileReaderService { + public override async readAsDataURL(file: File): Promise { + return await new Promise((resolve) => { + resolve(`data:${file.type};base64,MOCK_DATA`); + }); + } +} diff --git a/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.spec.ts b/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.spec.ts new file mode 100644 index 0000000000..2a454e1001 --- /dev/null +++ b/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.spec.ts @@ -0,0 +1,27 @@ +import { TestBed } from '@angular/core/testing'; +import { SkyFileReaderService } from '@skyux/core'; + +import { SkyFileReaderTestingService } from './file-reader-testing.service'; +import { provideSkyFileReaderTesting } from './provide-file-reader-testing'; + +describe('provideSkyFileReaderTesting', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideSkyFileReaderTesting()], + }); + }); + + it('should mock the service', () => { + const actualSvc = TestBed.inject(SkyFileReaderService); + + expect(actualSvc instanceof SkyFileReaderTestingService).toBe(true); + }); + + it('should return file url', async () => { + const file = new File([''], 'filename', { type: 'text/plain' }); + + await expectAsync( + TestBed.inject(SkyFileReaderService).readAsDataURL(file), + ).toBeResolvedTo('data:text/plain;base64,MOCK_DATA'); + }); +}); diff --git a/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.ts b/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.ts new file mode 100644 index 0000000000..8f5d02cc3f --- /dev/null +++ b/libs/components/core/testing/src/modules/file-reader/provide-file-reader-testing.ts @@ -0,0 +1,24 @@ +import { Provider } from '@angular/core'; +import { SkyFileReaderService } from '@skyux/core'; + +import { SkyFileReaderTestingService } from './file-reader-testing.service'; + +/** + * Provides mocks for file reader testing. + * @internal + * @example + * ```typescript + * TestBed.configureTestingModule({ + * providers: [provideSkyFileReaderTesting()] + * }); + * ``` + */ +export function provideSkyFileReaderTesting(): Provider[] { + return [ + SkyFileReaderTestingService, + { + provide: SkyFileReaderService, + useClass: SkyFileReaderTestingService, + }, + ]; +} diff --git a/libs/components/core/testing/src/public-api.ts b/libs/components/core/testing/src/public-api.ts index ef8a0efbe9..0a1bd150c1 100644 --- a/libs/components/core/testing/src/public-api.ts +++ b/libs/components/core/testing/src/public-api.ts @@ -6,6 +6,7 @@ export { mockResizeObserverEntry, mockResizeObserverHandle, } from './legacy/resize-observer-mock'; +export { provideSkyFileReaderTesting } from './modules/file-reader/provide-file-reader-testing'; export { SkyHelpTestingController } from './modules/help/help-testing-controller'; export { SkyHelpTestingModule } from './modules/help/help-testing.module'; export { SkyMediaQueryTestingController } from './modules/media-query/media-query-testing-controller'; diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.spec.ts b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.spec.ts index 46a65f09d3..739b4c2fe2 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.spec.ts +++ b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.spec.ts @@ -558,7 +558,7 @@ describe('File attachment', () => { }); // Maybe some other tests here about dragging - it('should load and emit file on file change event', () => { + it('should load and emit file on file change event', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -576,6 +576,7 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); expect(fileChangeActual?.file).toBeTruthy(); expect(fileChangeActual?.file?.url).toBe('$/url'); @@ -585,7 +586,7 @@ describe('File attachment', () => { expect(liveAnnouncerSpy.calls.count()).toBe(1); }); - 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: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -606,6 +607,7 @@ describe('File attachment', () => { fileReaderSpy.abortCallbacks[0](); fixture.detectChanges(); + await fixture.whenStable(); expect(filesChangedActual?.file?.url).toBeFalsy(); expect(filesChangedActual?.file?.file.name).toBe('woo.txt'); @@ -621,13 +623,14 @@ describe('File attachment', () => { fileReaderSpy.errorCallbacks[1](); fixture.detectChanges(); + await fixture.whenStable(); expect(filesChangedActual?.file?.url).toBeFalsy(); expect(filesChangedActual?.file?.file.name).toBe('foo.txt'); expect(filesChangedActual?.file?.file.size).toBe(2000); }); - it('should clear file on remove press', () => { + it('should clear file on remove press', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -645,6 +648,9 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); + fixture.detectChanges(); + liveAnnouncerSpy.calls.reset(); const deleteEl = getDeleteEl(); @@ -810,6 +816,7 @@ describe('File attachment', () => { }); fixture.detectChanges(); + await fixture.whenStable(); expect(fileChangeActual?.file).toBeTruthy(); expect(fileChangeActual?.file?.errorType).toBeFalsy(); @@ -873,6 +880,7 @@ describe('File attachment', () => { ]; const fileReaderSpy = setupStandardFileChangeEvent(initialFile); + await fixture.whenStable(); expect(fileChangeActual?.file).toBeTruthy(); expect(fileChangeActual?.file?.url).toBe('$/url'); @@ -910,6 +918,7 @@ describe('File attachment', () => { }); fixture.detectChanges(); + await fixture.whenStable(); expect(fileChangeActual?.file).toBeTruthy(); expect(fileChangeActual?.file?.errorType).toBeFalsy(); @@ -1020,7 +1029,7 @@ describe('File attachment', () => { expect(fileAttachmentInstance.value).toBeFalsy(); }); - it('should respect a default min file size of 0', () => { + it('should respect a default min file size of 0', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -1028,6 +1037,7 @@ describe('File attachment', () => { ); const spy = setupStandardFileChangeEvent(); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); @@ -1044,6 +1054,7 @@ describe('File attachment', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); @@ -1058,6 +1069,7 @@ describe('File attachment', () => { fixture.detectChanges(); setupStandardFileChangeEvent(undefined, spy); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); @@ -1171,7 +1183,7 @@ describe('File attachment', () => { expect(fileAttachmentInstance.value).toBeFalsy(); }); - it('should accept if file passes user provided validation function', () => { + it('should accept if file passes user provided validation function', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -1201,6 +1213,7 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); @@ -1209,7 +1222,7 @@ describe('File attachment', () => { expect(fileAttachmentInstance.value).toBeTruthy(); }); - it('should accept a file when type is accepted', () => { + it('should accept a file when type is accepted', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -1229,6 +1242,7 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); @@ -1288,7 +1302,7 @@ describe('File attachment', () => { expect(fileChangeActual?.file?.errorParam).toBe('PNG, TIFF'); }); - it('should allow the user to specify accepted type with wildcards', () => { + it('should allow the user to specify accepted type with wildcards', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -1308,13 +1322,14 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('woo.txt'); expect(fileChangeActual?.file?.file.size).toBe(2000); expect(fileChangeActual?.file?.url).toBe('$/url'); }); - it('should accept multiple types using a wildcard', () => { + it('should accept multiple types using a wildcard', async () => { let fileChangeActual: SkyFileAttachmentChange | undefined; fileAttachmentInstance.fileChange.subscribe( @@ -1334,6 +1349,7 @@ describe('File attachment', () => { ]; setupStandardFileChangeEvent(file); + await fixture.whenStable(); expect(fileChangeActual?.file?.file.name).toBe('foo.txt'); expect(fileChangeActual?.file?.file.size).toBe(1000); diff --git a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.ts b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.ts index b4c5f9dd6d..8c6c280449 100644 --- a/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.ts +++ b/libs/components/forms/src/lib/modules/file-attachment/file-attachment/file-attachment.component.ts @@ -28,6 +28,7 @@ import { Validators, } from '@angular/forms'; import { + SkyFileReaderService, SkyIdModule, SkyIdService, SkyLiveAnnouncerService, @@ -99,6 +100,8 @@ export class SkyFileAttachmentComponent OnInit, OnDestroy { + readonly #fileReaderSvc = inject(SkyFileReaderService); + /** * The comma-delimited string literal of MIME types that users can attach. * By default, all file types are allowed. @@ -552,14 +555,11 @@ export class SkyFileAttachmentComponent } } - #loadFile(file: SkyFileItem): void { + async #loadFile(file: SkyFileItem): Promise { if (file.file) { - const reader = new FileReader(); - - reader.addEventListener('load', (event: any): void => { + try { const previousFileName = this.value?.file.name; - file.url = event.target.result; - this.#emitFileChangeEvent(file); + if (previousFileName) { this.#announceState( 'skyux_file_attachment_file_upload_file_replaced', @@ -572,17 +572,13 @@ export class SkyFileAttachmentComponent file.file.name, ); } - }); - reader.addEventListener('error', (): void => { - this.#emitFileChangeEvent(file); - }); + file.url = await this.#fileReaderSvc.readAsDataURL(file.file); - reader.addEventListener('abort', (): void => { this.#emitFileChangeEvent(file); - }); - - reader.readAsDataURL(file.file); + } catch { + this.#emitFileChangeEvent(file); + } } } @@ -600,7 +596,7 @@ export class SkyFileAttachmentComponent if (file.errorType) { this.#emitFileChangeEvent(file); } else { - this.#loadFile(file); + void this.#loadFile(file); } } } diff --git a/libs/components/forms/testing/src/modules/file-attachment/shared/provide-file-attachment-testing.ts b/libs/components/forms/testing/src/modules/file-attachment/shared/provide-file-attachment-testing.ts new file mode 100644 index 0000000000..9d3755c928 --- /dev/null +++ b/libs/components/forms/testing/src/modules/file-attachment/shared/provide-file-attachment-testing.ts @@ -0,0 +1,15 @@ +import { Provider } from '@angular/core'; +import { provideSkyFileReaderTesting } from '@skyux/core/testing'; + +/** + * Provides mocks for file attachment testing. + * @example + * ```typescript + * TestBed.configureTestingModule({ + * providers: [provideSkyFileAttachmentTesting()] + * }); + * ``` + */ +export function provideSkyFileAttachmentTesting(): Provider[] { + return [provideSkyFileReaderTesting()]; +} diff --git a/libs/components/forms/testing/src/public-api.ts b/libs/components/forms/testing/src/public-api.ts index 67fd4fed85..352900a34d 100644 --- a/libs/components/forms/testing/src/public-api.ts +++ b/libs/components/forms/testing/src/public-api.ts @@ -13,6 +13,7 @@ export { SkyCheckboxLabelHarness } from './modules/checkbox/checkbox-label-harne export { SkyFileDropHarness } from './modules/file-attachment/file-drop/file-drop-harness'; export { SkyFileDropHarnessFilters } from './modules/file-attachment/file-drop/file-drop-harness-filters'; +export { provideSkyFileAttachmentTesting } from './modules/file-attachment/shared/provide-file-attachment-testing'; export { SkyFormErrorsHarness } from './modules/form-error/form-errors-harness'; export { SkyFormErrorsHarnessFilters } from './modules/form-error/form-errors-harness.filters';