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 a2ca0b83cb..b25e108925 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,16 +1,24 @@
+ > + @if (fileDrop.errors?.['maxNumberOfFilesReached']) { + + } +
@for (file of fileDrop.value; track file) { diff --git a/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.spec.ts new file mode 100644 index 0000000000..986e252791 --- /dev/null +++ b/apps/code-examples/src/app/code-examples/forms/file-drop/basic/demo.component.spec.ts @@ -0,0 +1,113 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + SkyFileDropHarness, + SkyFileItemHarness, + provideSkyFileAttachmentTesting, +} from '@skyux/forms/testing'; + +import { DemoComponent } from './demo.component'; + +describe('Basic file drop demo', () => { + async function setupTest(options: { dataSkyId: string }): Promise<{ + harness: SkyFileDropHarness; + formControl: FormControl; + loader: HarnessLoader; + }> { + TestBed.configureTestingModule({ + providers: [provideSkyFileAttachmentTesting()], + }); + const fixture = TestBed.createComponent(DemoComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + const harness = await loader.getHarness( + SkyFileDropHarness.with({ dataSkyId: options.dataSkyId }), + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + const formControl = fixture.componentInstance.fileDrop; + + return { harness, formControl, loader }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent, NoopAnimationsModule], + }); + }); + + it('should set initial values', async () => { + const { harness } = await setupTest({ + dataSkyId: 'logo-upload', + }); + + await expectAsync(harness.getLabelText()).toBeResolvedTo('Logo image'); + await expectAsync(harness.getAcceptedTypes()).toBeResolvedTo( + 'image/png,image/jpeg', + ); + await expectAsync(harness.getHintText()).toBeResolvedTo( + 'Upload up to 3 files under 50MB.', + ); + await expectAsync(harness.isStacked()).toBeResolvedTo(true); + + await expectAsync(harness.getLinkUploadHintText()).toBeResolvedTo( + 'Start with http:// or https://', + ); + }); + + it('should not upload invalid files starting with `a`', async () => { + const { harness, formControl } = await setupTest({ + dataSkyId: 'logo-upload', + }); + + const filesToUpload: File[] = [ + new File([], 'aWrongFile', { type: 'image/png' }), + new File([], 'validFile', { type: 'image/png' }), + ]; + + await harness.loadFiles(filesToUpload); + + expect(formControl.value?.length).toBe(1); + await expectAsync(harness.hasValidateFnError()).toBeResolvedTo(true); + }); + + it('should not allow more than 3 files to be uploaded', async () => { + const { harness, formControl, loader } = await setupTest({ + dataSkyId: 'logo-upload', + }); + + await harness.loadFiles([ + new File([], 'validFile1', { type: 'image/png' }), + new File([], 'validFile2', { type: 'image/png' }), + new File([], 'validFile3', { type: 'image/png' }), + ]); + + expect(formControl.value?.length).toBe(3); + await expectAsync( + harness.hasCustomError('maxNumberOfFilesReached'), + ).toBeResolvedTo(false); + expect(formControl.valid).toBe(true); + + await harness.enterLinkUploadText('foo.bar'); + await harness.clickLinkUploadDoneButton(); + + expect(formControl.value?.length).toBe(4); + await expectAsync( + harness.hasCustomError('maxNumberOfFilesReached'), + ).toBeResolvedTo(true); + expect(formControl.valid).toBe(false); + + const validFileItemHarness = await loader.getHarness( + SkyFileItemHarness.with({ fileName: 'validFile2' }), + ); + await validFileItemHarness.clickDeleteButton(); + + expect(formControl.value?.length).toBe(3); + expect(formControl.valid).toBe(true); + }); +}); 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 1ad3da1d3e..3d5b9be1f5 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,16 +1,32 @@ import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core'; import { + AbstractControl, FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, + ValidationErrors, Validators, } from '@angular/forms'; import { SkyFileDropModule, SkyFileItem, SkyFileLink } from '@skyux/forms'; import { SkyStatusIndicatorModule } from '@skyux/indicators'; +/** + * Demonstrates how to create a custom validator function for your form control. + */ +function customValidator( + control: AbstractControl<(SkyFileItem | SkyFileLink)[] | null | undefined>, +): ValidationErrors | null { + if (control.value !== undefined && control.value !== null) { + if (control.value.length > 3) { + return { maxNumberOfFilesReached: true }; + } + } + return null; +} + @Component({ selector: 'app-demo', templateUrl: './demo.component.html', @@ -24,19 +40,17 @@ import { SkyStatusIndicatorModule } from '@skyux/indicators'; }) export class DemoComponent { protected acceptedTypes = 'image/png,image/jpeg'; - protected allItems: (SkyFileItem | SkyFileLink)[] = []; - protected hintText = '5 MB maximum'; + protected hintText = 'Upload up to 3 files under 50MB.'; protected inlineHelpContent = 'Your logo appears in places such as authentication pages, student and parent portals, and extracurricular home pages.'; protected labelText = 'Logo image'; protected maxFileSize = 5242880; - protected rejectedFiles: SkyFileItem[] = []; protected stacked = 'true'; - protected fileDrop = new FormControl< + public fileDrop = new FormControl< (SkyFileItem | SkyFileLink)[] | null | undefined - >(undefined, Validators.required); - protected formGroup: FormGroup = inject(FormBuilder).group({ + >(undefined, [Validators.required, customValidator]); + public formGroup: FormGroup = inject(FormBuilder).group({ fileDrop: this.fileDrop, }); @@ -45,7 +59,13 @@ export class DemoComponent { if (index !== undefined && index !== -1) { this.fileDrop.value?.splice(index, 1); + /* + If you are adding custom validation through the form control, + be sure to include this line after deleting a file from the form. + */ + this.fileDrop.updateValueAndValidity(); } + // To ensure that empty arrays throw required errors, include this check. if (this.fileDrop.value?.length === 0) { this.fileDrop.setValue(null); } diff --git a/libs/components/avatar/testing/src/modules/avatar/avatar-harness.ts b/libs/components/avatar/testing/src/modules/avatar/avatar-harness.ts index a6a0bc34fd..aa97ee2b5e 100644 --- a/libs/components/avatar/testing/src/modules/avatar/avatar-harness.ts +++ b/libs/components/avatar/testing/src/modules/avatar/avatar-harness.ts @@ -88,7 +88,7 @@ export class SkyAvatarHarness extends SkyComponentHarness { if (waitForChange) { await this.#dropAndWait(fileDrop, file); } else { - await fileDrop.dropFile(file); + await fileDrop.loadFile(file); } } @@ -136,7 +136,7 @@ export class SkyAvatarHarness extends SkyComponentHarness { async #dropAndWait(fileDrop: SkyFileDropHarness, file: File): Promise { const currentUrl = await this.#getImageUrl(); - await fileDrop.dropFile(file); + await fileDrop.loadFile(file); return await new Promise((resolve, reject) => { const checkForFileChange = async (attempts: number): Promise => { 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 93a7d13688..72a0d0b526 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 @@ -202,6 +202,7 @@ [dirty]="ngControl?.dirty" [errors]="ngControl?.errors" > + @for (rejectedFile of rejectedFiles; track rejectedFile) {
@if (rejectedFile.errorType === 'fileType') { 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 index 666dcf4c48..e4b11420ac 100644 --- 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 @@ -7,6 +7,6 @@ /> -{{fileDrop.value | json}} @for (file of fileDrop.value; track file) { +@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 index eef8088cef..7a0ccf5327 100644 --- 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 @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core'; import { FormBuilder, @@ -14,7 +13,7 @@ import { SkyFileDropModule } from '../file-drop.module'; import { SkyFileLink } from '../file-link'; @Component({ - imports: [SkyFileDropModule, FormsModule, ReactiveFormsModule, CommonModule], + imports: [SkyFileDropModule, FormsModule, ReactiveFormsModule], selector: 'sky-file-drop-reactive-test', standalone: true, templateUrl: './reactive-file-drop.component.fixture.html', diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.spec.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.spec.ts index 87913dbbe4..e6f030fc31 100644 --- a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.spec.ts +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.spec.ts @@ -1,24 +1,95 @@ +import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SkyFileDropChange, SkyFileDropModule } from '@skyux/forms'; - -import { ReplaySubject, firstValueFrom } from 'rxjs'; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideSkyFileReaderTesting } from '@skyux/core/testing'; +import { + SkyFileDropModule, + SkyFileItem, + SkyFileLink, + SkyFileValidateFn, +} from '@skyux/forms'; import { SkyFileDropHarness } from './file-drop-harness'; +import { SkyFileItemHarness } from './file-item-harness'; @Component({ - imports: [SkyFileDropModule], - template: ``, + standalone: true, + imports: [SkyFileDropModule, FormsModule, ReactiveFormsModule], + template: ` +
+ + @if (showCustomError) { + + } + +
+ @for (file of fileDrop.value; track file) { + + } + `, }) class TestComponent { - public filesChanged = new ReplaySubject(1); + public acceptedTypes: string | undefined; + public allItems: (SkyFileItem | SkyFileLink)[] = []; + public allowLinks = true; + public fileUploadAriaLabel: string | undefined; + public helpPopoverContent: string | undefined; + public helpPopoverTitle: string | undefined; + public hintText: string | undefined; + public labelHidden = false; + public labelText: string | undefined = 'File upload'; + public linkUploadAriaLabel: string | undefined; + public linkUploadHintText: string | undefined; + public maxFileSize: number | undefined; + public minFileSize: number | undefined; + public required = false; + public showCustomError = false; + public stacked = false; + public validateFunction: SkyFileValidateFn | undefined; + + public fileDrop: FormControl = new FormControl(undefined); + public formGroup: FormGroup = inject(FormBuilder).group({ + fileDrop: this.fileDrop, + }); - public onFilesChanged(event: SkyFileDropChange): void { - this.filesChanged.next(event); + 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); + } } } @@ -26,7 +97,13 @@ describe('File drop harness', () => { async function setupTest(): Promise<{ harness: SkyFileDropHarness; fixture: ComponentFixture; + loader: HarnessLoader; }> { + await TestBed.configureTestingModule({ + imports: [TestComponent, NoopAnimationsModule], + providers: [provideSkyFileReaderTesting, provideSkyFileReaderTesting()], + }).compileComponents(); + const fixture = TestBed.createComponent(TestComponent); const loader = TestbedHarnessEnvironment.loader(fixture); const harness: SkyFileDropHarness = await loader.getHarness( @@ -35,28 +112,338 @@ describe('File drop harness', () => { }), ); - return { harness, fixture }; + return { harness, fixture, loader }; } - it('should drop files', async () => { + it('should drop a single file', async () => { const { fixture, harness } = await setupTest(); const testFile = new File([], 'test.png'); - const changedFiles = firstValueFrom(fixture.componentInstance.filesChanged); + await harness.loadFile(testFile); + + expect(fixture.componentInstance.fileDrop.value).toEqual([ + { + file: testFile, + url: jasmine.any(String), + }, + ]); + }); + + it('should get accepted types', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.acceptedTypes = 'image/png'; + fixture.detectChanges(); - await harness.dropFile(testFile); + await expectAsync(harness.getAcceptedTypes()).toBeResolvedTo('image/png'); + }); + + it('should get file upload button aria-label', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.fileUploadAriaLabel = + 'this is an accessibility label.'; + fixture.detectChanges(); + + await expectAsync(harness.getFileUploadAriaLabel()).toBeResolvedTo( + 'this is an accessibility label.', + ); + }); + + it('should click the file upload button', async () => { + const { fixture, harness } = await setupTest(); + + const input = fixture.nativeElement.querySelector( + 'input.sky-file-input-hidden', + ); + spyOn(input, 'click'); + + await harness.clickFileDropTarget(); + expect(input.click).toHaveBeenCalled(); + }); + + it('should throw an error if there is no help inline', async () => { + const { harness } = await setupTest(); + + await expectAsync(harness.getHelpPopoverContent()).toBeRejectedWithError( + 'No help inline found.', + ); + }); + + it('should get help popover content', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.helpPopoverContent = 'Upload a file'; + fixture.detectChanges(); + + await harness.clickHelpInline(); + + await expectAsync(harness.getHelpPopoverContent()).toBeResolvedTo( + 'Upload a file', + ); + }); + + it('should get help popover title', async () => { + const { fixture, harness } = await setupTest(); - const files = await changedFiles; + fixture.componentInstance.helpPopoverContent = 'Upload a file'; + fixture.componentInstance.helpPopoverTitle = 'Help'; + fixture.detectChanges(); - expect(files).toEqual({ - files: [ + await harness.clickHelpInline(); + + await expectAsync(harness.getHelpPopoverTitle()).toBeResolvedTo('Help'); + }); + + it('should get hint text', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.hintText = 'this is a hint.'; + fixture.detectChanges(); + + await expectAsync(harness.getHintText()).toBeResolvedTo('this is a hint.'); + }); + + it('should get label text', async () => { + const { harness } = await setupTest(); + + await expectAsync(harness.getLabelText()).toBeResolvedTo('File upload'); + }); + + it('should get whether the label is hidden', async () => { + const { fixture, harness } = await setupTest(); + + await expectAsync(harness.isLabelHidden()).toBeResolvedTo(false); + + fixture.componentInstance.labelHidden = true; + fixture.detectChanges(); + + await expectAsync(harness.isLabelHidden()).toBeResolvedTo(true); + }); + + it('should get whether file drop is required', async () => { + const { fixture, harness } = await setupTest(); + + await expectAsync(harness.isRequired()).toBeResolvedTo(false); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + await expectAsync(harness.isRequired()).toBeResolvedTo(true); + }); + + it('should get whether file drop is stacked', async () => { + const { fixture, harness } = await setupTest(); + + await expectAsync(harness.isStacked()).toBeResolvedTo(false); + + fixture.componentInstance.stacked = true; + fixture.detectChanges(); + + await expectAsync(harness.isStacked()).toBeResolvedTo(true); + }); + + it('should get whether required error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + fixture.componentInstance.fileDrop.markAsTouched(); + fixture.detectChanges(); + + await expectAsync(harness.hasRequiredError()).toBeResolvedTo(true); + }); + + it('should get whether file type error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.acceptedTypes = 'image/png'; + fixture.detectChanges(); + + await harness.loadFiles([ + new File([], 'wrongFile.jpg', { type: 'image/jpg' }), + ]); + + await expectAsync(harness.hasFileTypeError()).toBeResolvedTo(true); + }); + + it('should get whether max file type error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.maxFileSize = -1; + fixture.detectChanges(); + + await harness.loadFiles([ + new File([], 'wrongFile.jpg', { type: 'image/jpg' }), + ]); + + await expectAsync(harness.hasMaxFileSizeError()).toBeResolvedTo(true); + }); + + it('should get whether min file type error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.minFileSize = 1000; + fixture.detectChanges(); + + await harness.loadFiles([ + new File([], 'wrongFile.jpg', { type: 'image/jpg' }), + ]); + + await expectAsync(harness.hasMinFileSizeError()).toBeResolvedTo(true); + }); + + it('should get whether validate file type error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.validateFunction = function ( + file: SkyFileItem, + ): string | undefined { + return file.file.name.startsWith('a') + ? 'Upload a file that does not begin with the letter "a"' + : undefined; + }; + fixture.detectChanges(); + + await harness.loadFiles([ + new File([], 'aWrongFile.jpg', { type: 'image/jpg' }), + ]); + + await expectAsync(harness.hasValidateFnError()).toBeResolvedTo(true); + }); + + it('should get whether custom error has fired', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.fileDrop.markAsTouched(); + fixture.detectChanges(); + fixture.componentInstance.showCustomError = true; + fixture.detectChanges(); + + await expectAsync(harness.hasCustomError('customError')).toBeResolvedTo( + true, + ); + }); + + describe('upload link harness', () => { + it('should get upload link aria-label', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.linkUploadAriaLabel = 'upload aria-label'; + fixture.detectChanges(); + + await expectAsync(harness.getLinkUploadAriaLabel()).toBeResolvedTo( + 'upload aria-label', + ); + }); + + it('should get the upload link hint text', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.linkUploadHintText = 'link hint text'; + fixture.detectChanges(); + + await expectAsync(harness.getLinkUploadHintText()).toBeResolvedTo( + 'link hint text', + ); + }); + + it('should throw an error clicking a disabled upload link button', async () => { + const { harness } = await setupTest(); + + await expectAsync( + harness.clickLinkUploadDoneButton(), + ).toBeRejectedWithError( + 'Done button is disabled and cannot be clicked. Enter text to enable link upload.', + ); + }); + + it('should throw an error if attempting to interact with link upload when links are not allowed', async () => { + const { fixture, harness } = await setupTest(); + + fixture.componentInstance.allowLinks = false; + fixture.detectChanges(); + + await expectAsync(harness.getLinkUploadAriaLabel()).toBeRejectedWithError( + 'Link upload cannot be found. Set `allowLinks` property to `true`.', + ); + }); + + it('should upload a link', async () => { + const { fixture, harness } = await setupTest(); + + await harness.enterLinkUploadText('foo.bar'); + await harness.clickLinkUploadDoneButton(); + + expect(fixture.componentInstance.fileDrop.value).toEqual([ { - file: testFile, - url: jasmine.any(String), + url: 'foo.bar', }, - ], - rejectedFiles: [], + ]); + }); + }); + + describe('sky file item harness', () => { + async function getFileItemHarness( + harness: SkyFileDropHarness, + loader: HarnessLoader, + ): Promise { + await harness.loadFiles([ + new File(['a'.repeat(20)], 'FileName', { type: 'image/png' }), + ]); + return await loader.getHarness(SkyFileItemHarness); + } + it('should get the file name', async () => { + const { harness, loader } = await setupTest(); + + const fileItemHarness = await getFileItemHarness(harness, loader); + + await expectAsync(fileItemHarness.getFileName()).toBeResolvedTo( + 'FileName', + ); + }); + + it('should get the file size', async () => { + const { harness, loader } = await setupTest(); + + const fileItemHarness = await getFileItemHarness(harness, loader); + + await expectAsync(fileItemHarness.getFileSize()).toBeResolvedTo( + '20 bytes', + ); + }); + + it('should click the delete button', async () => { + const { fixture, harness, loader } = await setupTest(); + + const fileItemHarness = await getFileItemHarness(harness, loader); + + expect(fixture.componentInstance.fileDrop.value.length).toBe(1); + + await fileItemHarness.clickDeleteButton(); + + expect(fixture.componentInstance.fileDrop.value).toBe(null); + }); + + it('should get sky file item by file name', async () => { + const { harness, loader } = await setupTest(); + + await harness.loadFiles([ + new File(['a'.repeat(20)], 'FileName', { type: 'image/png' }), + new File(['a'.repeat(10)], 'OtherFile', { type: 'image/png' }), + ]); + + const fileItemHarness = await loader.getHarness( + SkyFileItemHarness.with({ + fileName: 'OtherFile', + }), + ); + + await expectAsync(fileItemHarness.getFileSize()).toBeResolvedTo( + '10 bytes', + ); }); }); }); diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.ts index 7cc20e8a22..22065c49d7 100644 --- a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.ts +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-harness.ts @@ -1,11 +1,14 @@ import { EventData, HarnessPredicate } from '@angular/cdk/testing'; import { SkyComponentHarness } from '@skyux/core/testing'; +import { SkyHelpInlineHarness } from '@skyux/help-inline/testing'; + +import { SkyFormErrorsHarness } from '../../form-error/form-errors-harness'; import { SkyFileDropHarnessFilters } from './file-drop-harness-filters'; +import { SkyFileDropLinkUploadHarness } from './file-drop-link-upload-harness'; /** * Harness for interacting with a file drop component in tests. - * @internal */ export class SkyFileDropHarness extends SkyComponentHarness { /** @@ -14,6 +17,10 @@ export class SkyFileDropHarness extends SkyComponentHarness { public static hostSelector = 'sky-file-drop'; #getDropTarget = this.locatorFor('.sky-file-drop-target'); + #input = this.locatorFor('input[type="file"]'); + #fileUploadButton = this.locatorFor('button.sky-file-drop-target'); + #label = this.locatorFor('.sky-file-drop-label-text'); + #formErrors = this.locatorForAll(SkyFormErrorsHarness); /** * Gets a `HarnessPredicate` that can be used to search for a @@ -26,25 +33,213 @@ export class SkyFileDropHarness extends SkyComponentHarness { } /** - * Drops a file onto the component's drop target. + * Clicks the file drop target. + */ + public async clickFileDropTarget(): Promise { + return await (await this.#fileUploadButton()).click(); + } + + /** + * Clicks the help inline button. + */ + public async clickHelpInline(): Promise { + return await (await this.#getHelpInline()).click(); + } + + /** + * Clicks the link upload `Done` button'. + */ + public async clickLinkUploadDoneButton(): Promise { + return await (await this.#getLinkUpload()).clickDoneButton(); + } + + /** + * Enters text into the link upload input. + */ + public async enterLinkUploadText(link: string): Promise { + return await (await this.#getLinkUpload()).enterText(link); + } + + /** + * Gets the accepted file types. + */ + public async getAcceptedTypes(): Promise { + return await (await this.#input()).getAttribute('accept'); + } + + /** + * Gets the aria-label for the file upload button. + */ + public async getFileUploadAriaLabel(): Promise { + return await (await this.#fileUploadButton()).getAttribute('aria-label'); + } + + /** + * Gets the help inline popover content. + */ + public async getHelpPopoverContent(): Promise { + const content = await (await this.#getHelpInline()).getPopoverContent(); + + return content as string | undefined; + } + + /** + * Gets the help inline popover title. + */ + public async getHelpPopoverTitle(): Promise { + return await (await this.#getHelpInline()).getPopoverTitle(); + } + + /** + * Gets the hint text. + */ + public async getHintText(): Promise { + return ( + await (await this.locatorFor('.sky-file-drop-hint-text')()).text() + ).trim(); + } + + /** + * Gets the label text. + */ + public async getLabelText(): Promise { + return (await (await this.#label()).text()).trim(); + } + + /** + * Gets the link upload aria-label. + */ + public async getLinkUploadAriaLabel(): Promise { + return await (await this.#getLinkUpload()).getAriaLabel(); + } + + /** + * Gets the link upload hint text. */ - public async dropFile(file: File): Promise { + public async getLinkUploadHintText(): Promise { + return await (await this.#getLinkUpload()).getHintText(); + } + + /** + * Whether a custom form error has fired. + */ + public async hasCustomError(errorName: string): Promise { + return await (await this.#getFormErrors()).hasError(errorName); + } + + /** + * Whether the file type error has fired. + */ + public async hasFileTypeError(): Promise { + return await (await this.#getFormErrors()).hasError('fileType'); + } + + /** + * Whether the max file size error has fired. + */ + public async hasMaxFileSizeError(): Promise { + return await (await this.#getFormErrors()).hasError('maxFileSize'); + } + + /** + * Whether the min file size error has fired. + */ + public async hasMinFileSizeError(): Promise { + return await (await this.#getFormErrors()).hasError('minFileSize'); + } + + /** + * Whether the required error has fired. + */ + public async hasRequiredError(): Promise { + return await (await this.#getFormErrors()).hasError('required'); + } + + /** + * Whether the validate error from the customer validation has fired. + */ + public async hasValidateFnError(): Promise { + return await (await this.#getFormErrors()).hasError('validate'); + } + + /** + * Whether label text is hidden. + */ + public async isLabelHidden(): Promise { + return await ( + await this.locatorFor('legend.sky-control-label')() + ).hasClass('sky-screen-reader-only'); + } + + /** + * Whether file drop is required. + */ + public async isRequired(): Promise { + return await (await this.#label()).hasClass('sky-control-label-required'); + } + + /** + * Whether file drop has stacked enabled. + */ + public async isStacked(): Promise { + return await (await this.host()).hasClass('sky-form-field-stacked'); + } + + /** + * Loads a single file. + */ + public async loadFile(file: File): Promise { await this.#dropFiles([file]); } - // Consider making this public when we finalize this harness's public API. - async #dropFiles(files: File[]): Promise { + /** + * Loads multiple files. + */ + public async loadFiles(files: File[] | null): Promise { + return await this.#dropFiles(files); + } + + async #dropFiles(files: File[] | null): Promise { const dropTarget = await this.#getDropTarget(); - const fileList = { - item: (index: number): File => files[index], - length: files.length, - }; + type TestDataTransfer = DataTransfer & { [key: string]: EventData }; + + const dataTransfer = new DataTransfer() as TestDataTransfer; + + files?.forEach((file) => { + dataTransfer.items.add(file); + }); await dropTarget.dispatchEvent('drop', { - dataTransfer: { - files: fileList as unknown as EventData, - }, + dataTransfer, }); } + + async #getFormErrors(): Promise { + return (await this.#formErrors())[1]; + } + + async #getHelpInline(): Promise { + const harness = await this.locatorForOptional(SkyHelpInlineHarness)(); + + if (harness) { + return harness; + } + + throw Error('No help inline found.'); + } + + async #getLinkUpload(): Promise { + const linkUpload = await this.locatorForOptional( + SkyFileDropLinkUploadHarness, + )(); + + if (linkUpload) { + return linkUpload; + } + + throw new Error( + 'Link upload cannot be found. Set `allowLinks` property to `true`.', + ); + } } diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-harness.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-harness.ts new file mode 100644 index 0000000000..0c58c9ac56 --- /dev/null +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-harness.ts @@ -0,0 +1,57 @@ +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyInputBoxHarness } from '../../input-box/input-box-harness'; + +import { SkyFileDropLinkUploadInputHarness } from './file-drop-link-upload-input-harness'; + +/** + * Harness for interacting with file drop component's link upload feature in tests. + * @internal + */ +export class SkyFileDropLinkUploadHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = '.sky-file-drop-link'; + + #input = this.locatorFor(SkyFileDropLinkUploadInputHarness); + #inputBoxHarness = this.locatorFor(SkyInputBoxHarness); + #button = this.locatorFor('button.sky-btn-primary'); + + /** + * Clicks the `Done` button + */ + public async clickDoneButton(): Promise { + if (await this.#isDoneDisabled()) { + throw new Error( + 'Done button is disabled and cannot be clicked. Enter text to enable link upload.', + ); + } + return await (await this.#button()).click(); + } + + /** + * Enters text into the link upload input. + */ + public async enterText(link: string): Promise { + return await (await this.#input()).setValue(link); + } + + /** + * Gets the link upload aria-label. + */ + public async getAriaLabel(): Promise { + return await (await this.#input()).getAriaLabel(); + } + + /** + * Gets the hint text. + */ + public async getHintText(): Promise { + return await (await this.#inputBoxHarness()).getHintText(); + } + + async #isDoneDisabled(): Promise { + return (await (await this.#button()).getAttribute('disabled')) !== null; + } +} diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-input-harness.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-input-harness.ts new file mode 100644 index 0000000000..1c46234066 --- /dev/null +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-drop-link-upload-input-harness.ts @@ -0,0 +1,18 @@ +import { SkyInputHarness } from '@skyux/core/testing'; + +/** + * Harness to interact with the file drop link upload input harness. + */ +export class SkyFileDropLinkUploadInputHarness extends SkyInputHarness { + /** + * @internal + */ + public static hostSelector = 'input.sky-form-control'; + + /** + * Gets the input aria-label + */ + public async getAriaLabel(): Promise { + return await (await this.host()).getAttribute('aria-label'); + } +} diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness-filters.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness-filters.ts new file mode 100644 index 0000000000..0a32805352 --- /dev/null +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness-filters.ts @@ -0,0 +1,12 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyFileItemHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyFileItemHarnessFilters extends SkyHarnessFilters { + /** + * Finds files whose file name matches this value. + */ + fileName: string; +} diff --git a/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness.ts b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness.ts new file mode 100644 index 0000000000..e4f4739b84 --- /dev/null +++ b/libs/components/forms/testing/src/modules/file-attachment/file-drop/file-item-harness.ts @@ -0,0 +1,52 @@ +import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; + +import { SkyFileItemHarnessFilters } from './file-item-harness-filters'; + +/** + * Harness for interacting with a file item component in tests. + */ +export class SkyFileItemHarness extends ComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-file-item'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyFileItemHarness` that meets certain criteria. + */ + public static with( + filters: SkyFileItemHarnessFilters, + ): HarnessPredicate { + return new HarnessPredicate(SkyFileItemHarness, filters).addOption( + 'fileName', + filters.fileName, + async (harness, fileName) => { + const harnessFileName = await harness.getFileName(); + return await HarnessPredicate.stringMatches(harnessFileName, fileName); + }, + ); + } + + /** + * Clicks the delete button. + */ + public async clickDeleteButton(): Promise { + return await (await this.locatorFor('.sky-file-item-btn-delete')()).click(); + } + + /** + * Gets the file name. + */ + public async getFileName(): Promise { + return await (await this.locatorFor('.sky-file-item-name')()).text(); + } + + /** + * Gets the file size. + */ + public async getFileSize(): Promise { + const size = await (await this.locatorFor('.sky-file-item-size')()).text(); + return size.substring(1, size.length - 1); + } +} diff --git a/libs/components/forms/testing/src/public-api.ts b/libs/components/forms/testing/src/public-api.ts index 2a2e276df7..f4d55523bc 100644 --- a/libs/components/forms/testing/src/public-api.ts +++ b/libs/components/forms/testing/src/public-api.ts @@ -17,6 +17,8 @@ export { SkyFileAttachmentHarnessFilters } from './modules/file-attachment/file- 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 { SkyFileItemHarness } from './modules/file-attachment/file-drop/file-item-harness'; +export { SkyFileItemHarnessFilters } from './modules/file-attachment/file-drop/file-item-harness-filters'; export { SkyFormErrorsHarness } from './modules/form-error/form-errors-harness'; export { SkyFormErrorsHarnessFilters } from './modules/form-error/form-errors-harness.filters';