From 84e9603c4bf39897333452852c35a0140bd10b37 Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Fri, 10 Jan 2025 10:46:10 -0500 Subject: [PATCH 01/12] fix(components/ag-grid): handle focus for adjacent editor cells (#2993) (#3005) :cherries: Cherry picked from #2993 [fix(components/ag-grid): handle focus for adjacent editor cells](https://github.com/blackbaud/skyux/pull/2993) [AB#3196372](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/3196372) Co-authored-by: John White <750350+johnhwhite@users.noreply.github.com> --- ...cell-editor-autocomplete.component.spec.ts | 42 +++++++---- .../cell-editor-autocomplete.component.ts | 37 +++++----- .../cell-editor-currency.component.spec.ts | 15 ++-- .../cell-editor-currency.component.ts | 71 ++++++++++--------- .../cell-editor-datepicker.component.spec.ts | 47 +++++++----- .../cell-editor-datepicker.component.ts | 65 +++++++++-------- .../cell-editor-text.component.spec.ts | 62 ++++++++++------ .../cell-editor-text.component.ts | 15 ++-- 8 files changed, 207 insertions(+), 147 deletions(-) diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.spec.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.spec.ts index 314d4947da..3cba2a3446 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.spec.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.spec.ts @@ -1,4 +1,9 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; import { expect, expectAsync } from '@skyux-sdk/testing'; import { @@ -85,8 +90,9 @@ describe('SkyCellEditorAutocompleteComponent', () => { expect(component.editorForm.get('selection')?.value).toEqual(selection); }); - it('should respond to focus changes', () => { + it('should respond to focus changes', fakeAsync(() => { component.agInit(cellEditorParams as SkyCellEditorAutocompleteParams); + tick(); component.onAutocompleteOpenChange(true); component.onBlur(); @@ -95,7 +101,7 @@ describe('SkyCellEditorAutocompleteComponent', () => { component.onAutocompleteOpenChange(false); component.onBlur(); expect(cellEditorParams.api?.stopEditing).toHaveBeenCalled(); - }); + })); it('should set the correct aria label', () => { api.getDisplayNameForColumn.and.returnValue('Testing'); @@ -282,20 +288,21 @@ describe('SkyCellEditorAutocompleteComponent', () => { }; }); - it('should focus on the input after it attaches to the DOM', () => { + it('should focus on the input after it attaches to the DOM', fakeAsync(() => { fixture.detectChanges(); const input = nativeElement.querySelector('input') as HTMLInputElement; spyOn(input, 'focus'); component.afterGuiAttached(); + tick(); expect(input).toBeVisible(); expect(input.focus).toHaveBeenCalled(); - }); + })); describe('cellStartedEdit is true', () => { - it('does not select the input value if Backspace triggers the edit', () => { + it('does not select the input value if Backspace triggers the edit', fakeAsync(() => { component.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.BACKSPACE, @@ -305,12 +312,13 @@ describe('SkyCellEditorAutocompleteComponent', () => { const selectSpy = spyOn(input, 'select'); component.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if Delete triggers the edit', () => { + it('does not select the input value if Delete triggers the edit', fakeAsync(() => { component.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.DELETE, @@ -320,12 +328,13 @@ describe('SkyCellEditorAutocompleteComponent', () => { const selectSpy = spyOn(input, 'select'); component.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if F2 triggers the edit', () => { + it('does not select the input value if F2 triggers the edit', fakeAsync(() => { component.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.F2, @@ -335,12 +344,13 @@ describe('SkyCellEditorAutocompleteComponent', () => { const selectSpy = spyOn(input, 'select'); component.afterGuiAttached(); + tick(); expect(input.value).toBe(selection.name); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('selects the input value if Enter triggers the edit', () => { + it('selects the input value if Enter triggers the edit', fakeAsync(() => { component.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.ENTER, @@ -350,12 +360,13 @@ describe('SkyCellEditorAutocompleteComponent', () => { const selectSpy = spyOn(input, 'select'); component.afterGuiAttached(); + tick(); expect(input.value).toBe(selection.name); expect(selectSpy).toHaveBeenCalledTimes(1); - }); + })); - it('does not select the input value when a standard keyboard event triggers the edit', () => { + it('does not select the input value when a standard keyboard event triggers the edit', fakeAsync(() => { component.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: 'a', @@ -365,11 +376,12 @@ describe('SkyCellEditorAutocompleteComponent', () => { const selectSpy = spyOn(input, 'select').and.callThrough(); component.afterGuiAttached(); + tick(); fixture.detectChanges(); expect(input.value).toBe('a'); expect(selectSpy).toHaveBeenCalledTimes(1); - }); + })); }); describe('cellStartedEdit is false', () => { diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.ts index dff23da90b..0564d7ada3 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-autocomplete/cell-editor-autocomplete.component.ts @@ -75,24 +75,29 @@ export class SkyAgGridCellEditorAutocompleteComponent } public afterGuiAttached(): void { - if (this.input) { - this.input.nativeElement.focus(); - if (this.#triggerType === SkyAgGridCellEditorInitialAction.Replace) { - const charPress = this.#params?.eventKey as string; + // AG Grid sets focus to the cell via setTimeout, and this queues the input to focus after that. + setTimeout(() => { + if (this.input) { + this.input.nativeElement.focus(); + if (this.#triggerType === SkyAgGridCellEditorInitialAction.Replace) { + const charPress = this.#params?.eventKey as string; - this.input.nativeElement.select(); - this.input.nativeElement.setRangeText(charPress); - // Ensure the cursor is at the end of the text. - this.input.nativeElement.setSelectionRange( - charPress.length, - charPress.length, - ); - this.input.nativeElement.dispatchEvent(new Event('input')); + this.input.nativeElement.select(); + this.input.nativeElement.setRangeText(charPress); + // Ensure the cursor is at the end of the text. + this.input.nativeElement.setSelectionRange( + charPress.length, + charPress.length, + ); + this.input.nativeElement.dispatchEvent(new Event('input')); + } + if ( + this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted + ) { + this.input.nativeElement.select(); + } } - if (this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted) { - this.input.nativeElement.select(); - } - } + }); } public getValue(): any | undefined { diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.spec.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.spec.ts index 02815d5ebc..0c6ca7f41c 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.spec.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.spec.ts @@ -91,7 +91,7 @@ describe('SkyCellEditorCurrencyComponent', () => { }; }); - it('initializes the SkyAgGridCellEditorCurrencyComponent properties', () => { + it('initializes the SkyAgGridCellEditorCurrencyComponent properties', fakeAsync(() => { expect(currencyEditorComponent.columnWidth).toBeUndefined(); cellEditorParams.node = new RowNode({} as BeanCollection); @@ -113,10 +113,11 @@ describe('SkyCellEditorCurrencyComponent', () => { currencyEditorComponent.onPressEnter(); currencyEditorComponent.afterGuiAttached(); + tick(); expect(cellEditorParams.stopEditing).not.toHaveBeenCalled(); currencyEditorComponent.onPressEnter(); expect(cellEditorParams.stopEditing).toHaveBeenCalled(); - }); + })); it('should set the correct aria label', () => { api.getDisplayNameForColumn.and.returnValue('Testing'); @@ -189,7 +190,7 @@ describe('SkyCellEditorCurrencyComponent', () => { }; }); - it('sets the form control value correctly', () => { + it('sets the form control value correctly', fakeAsync(() => { expect( currencyEditorComponent.editorForm.get('currency')?.value, ).toBeNull(); @@ -197,11 +198,12 @@ describe('SkyCellEditorCurrencyComponent', () => { currencyEditorComponent.agInit(cellEditorParams as ICellEditorParams); currencyEditorFixture.detectChanges(); currencyEditorComponent.afterGuiAttached(); + tick(); expect( currencyEditorComponent.editorForm.get('currency')?.value, ).toEqual(value); - }); + })); describe('cellStartedEdit is true', () => { it('initializes with a cleared value unselected when Backspace triggers the edit', fakeAsync(() => { @@ -497,7 +499,7 @@ describe('SkyCellEditorCurrencyComponent', () => { })); }); - it('focuses on the input after it attaches to the DOM', () => { + it('focuses on the input after it attaches to the DOM', fakeAsync(() => { currencyEditorComponent.agInit(cellEditorParams as ICellEditorParams); currencyEditorFixture.detectChanges(); @@ -507,10 +509,11 @@ describe('SkyCellEditorCurrencyComponent', () => { spyOn(input, 'focus'); currencyEditorComponent.afterGuiAttached(); + tick(); expect(input).toBeVisible(); expect(input.focus).toHaveBeenCalled(); - }); + })); }); it('returns undefined if the value is not set', () => { diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.ts index ce5401f8a1..f42caa6b10 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-currency/cell-editor-currency.component.ts @@ -83,44 +83,47 @@ export class SkyAgGridCellEditorCurrencyComponent * afterGuiAttached is called by agGrid after the editor is rendered in the DOM. Once it is attached the editor is ready to be focused on. */ public afterGuiAttached(): void { - this.input?.nativeElement.focus(); - - // This setup is in `afterGuiAttached` due to the lifecycle of autonumeric which will highlight the initial value if it is in place when it renders. - // Since we don't want that, we set the initial value after autonumeric initializes. - this.#triggerType = SkyAgGridCellEditorUtils.getEditorInitialAction( - this.params, - ); - const control = this.currency; - - if (control) { - switch (this.#triggerType) { - case SkyAgGridCellEditorInitialAction.Delete: - control.setValue(undefined); - break; - case SkyAgGridCellEditorInitialAction.Replace: - control.setValue( - parseFloat(String(this.params?.eventKey)) || undefined, - ); - break; - case SkyAgGridCellEditorInitialAction.Highlighted: - case SkyAgGridCellEditorInitialAction.Untouched: - default: - control.setValue(parseFloat(String(this.params?.value))); - break; + // AG Grid sets focus to the cell via setTimeout, and this queues the input to focus after that. + setTimeout(() => { + this.input?.nativeElement.focus(); + + // This setup is in `afterGuiAttached` due to the lifecycle of autonumeric which will highlight the initial value if it is in place when it renders. + // Since we don't want that, we set the initial value after autonumeric initializes. + this.#triggerType = SkyAgGridCellEditorUtils.getEditorInitialAction( + this.params, + ); + const control = this.currency; + + if (control) { + switch (this.#triggerType) { + case SkyAgGridCellEditorInitialAction.Delete: + control.setValue(undefined); + break; + case SkyAgGridCellEditorInitialAction.Replace: + control.setValue( + parseFloat(String(this.params?.eventKey)) || undefined, + ); + break; + case SkyAgGridCellEditorInitialAction.Highlighted: + case SkyAgGridCellEditorInitialAction.Untouched: + default: + control.setValue(parseFloat(String(this.params?.value))); + break; + } } - } - this.#changeDetector.markForCheck(); + this.#changeDetector.markForCheck(); - if ( - this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted && - (this.params?.value ?? '') !== '' - ) { - this.input?.nativeElement.select(); - } + if ( + this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted && + (this.params?.value ?? '') !== '' + ) { + this.input?.nativeElement.select(); + } - // When the cell is initialized with the Enter key, we need to suppress the first `onPressEnter`. - this.#initialized = this.params?.eventKey !== 'Enter'; + // When the cell is initialized with the Enter key, we need to suppress the first `onPressEnter`. + this.#initialized = this.params?.eventKey !== 'Enter'; + }); } /** diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.spec.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.spec.ts index af136803b6..a688a8540c 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.spec.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.spec.ts @@ -475,7 +475,7 @@ describe('SkyCellEditorDatepickerComponent', () => { }; }); - it('focuses on the datepicker input after it attaches to the DOM', () => { + it('focuses on the datepicker input after it attaches to the DOM', fakeAsync(() => { datepickerEditorComponent.editorForm .get('date') ?.setValue(new Date('7/12/2019')); @@ -488,13 +488,14 @@ describe('SkyCellEditorDatepickerComponent', () => { spyOn(input, 'focus'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input).toBeVisible(); expect(input.focus).toHaveBeenCalled(); - }); + })); describe('cellStartedEdit is true', () => { - it('does not select the input value if Backspace triggers the edit', () => { + it('does not select the input value if Backspace triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.BACKSPACE, @@ -506,12 +507,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if Delete triggers the edit', () => { + it('does not select the input value if Delete triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.DELETE, @@ -523,12 +525,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if F2 triggers the edit', () => { + it('does not select the input value if F2 triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.F2, @@ -540,12 +543,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('selects the input value if Enter triggers the edit', () => { + it('selects the input value if Enter triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.ENTER, @@ -557,10 +561,11 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).toHaveBeenCalledTimes(1); - }); + })); it('does not select the input value when a standard keyboard event triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ @@ -580,6 +585,7 @@ describe('SkyCellEditorDatepickerComponent', () => { ).and.callThrough(); datepickerEditorComponent.afterGuiAttached(); + tick(); datepickerEditorFixture.detectChanges(); expect(input.value).toBe('a'); @@ -598,7 +604,7 @@ describe('SkyCellEditorDatepickerComponent', () => { cellEditorParams.cellStartedEdit = false; }); - it('does not select the input value if Backspace triggers the edit', () => { + it('does not select the input value if Backspace triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.BACKSPACE, @@ -610,12 +616,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if Delete triggers the edit', () => { + it('does not select the input value if Delete triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.DELETE, @@ -627,12 +634,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if F2 triggers the edit', () => { + it('does not select the input value if F2 triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.F2, @@ -644,12 +652,13 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('selects the input value if Enter triggers the edit', () => { + it('selects the input value if Enter triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.ENTER, @@ -661,10 +670,11 @@ describe('SkyCellEditorDatepickerComponent', () => { const selectSpy = spyOn(input, 'select'); datepickerEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(dateString); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); it('does not select the input value when a standard keyboard event triggers the edit', fakeAsync(() => { datepickerEditorComponent.agInit({ @@ -684,6 +694,7 @@ describe('SkyCellEditorDatepickerComponent', () => { ).and.callThrough(); datepickerEditorComponent.afterGuiAttached(); + tick(); datepickerEditorFixture.detectChanges(); expect(input.value).toBe(dateString); diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.ts index 743f55ffa4..7609132728 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-datepicker/cell-editor-datepicker.component.ts @@ -142,39 +142,44 @@ export class SkyAgGridCellEditorDatepickerComponent * afterGuiAttached is called by agGrid after the editor is rendered in the DOM. Once it is attached the editor is ready to be focused on. */ public afterGuiAttached(): void { - const datepickerInputEl = this.datepickerInput?.nativeElement as - | HTMLInputElement - | undefined; - - if (datepickerInputEl) { - datepickerInputEl.focus(); - - // programmatically set the value of in the input; however, do not do this via the form control so that the value is not formatted when editing starts. - // Watch for the first blur and fire a 'change' event as programmatic changes won't queue a change event, but we need to do this so that formatting happens if the user tabs to the calendar button. - if (this.#triggerType === SkyAgGridCellEditorInitialAction.Replace) { - fromEvent(datepickerInputEl, 'blur') - .pipe(first()) - .subscribe(() => { - datepickerInputEl.dispatchEvent(new Event('change')); - }); - datepickerInputEl.select(); - - const charPress = this.#params?.eventKey as string; - - if (charPress) { - datepickerInputEl.setRangeText(charPress); - // Ensure the cursor is at the end of the text. - datepickerInputEl.setSelectionRange( - charPress.length, - charPress.length, - ); + // AG Grid sets focus to the cell via setTimeout, and this queues the input to focus after that. + setTimeout(() => { + const datepickerInputEl = this.datepickerInput?.nativeElement as + | HTMLInputElement + | undefined; + + if (datepickerInputEl) { + datepickerInputEl.focus(); + + // programmatically set the value of in the input; however, do not do this via the form control so that the value is not formatted when editing starts. + // Watch for the first blur and fire a 'change' event as programmatic changes won't queue a change event, but we need to do this so that formatting happens if the user tabs to the calendar button. + if (this.#triggerType === SkyAgGridCellEditorInitialAction.Replace) { + fromEvent(datepickerInputEl, 'blur') + .pipe(first()) + .subscribe(() => { + datepickerInputEl.dispatchEvent(new Event('change')); + }); + datepickerInputEl.select(); + + const charPress = this.#params?.eventKey as string; + + if (charPress) { + datepickerInputEl.setRangeText(charPress); + // Ensure the cursor is at the end of the text. + datepickerInputEl.setSelectionRange( + charPress.length, + charPress.length, + ); + } } - } - if (this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted) { - datepickerInputEl.select(); + if ( + this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted + ) { + datepickerInputEl.select(); + } } - } + }); } /** diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.spec.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.spec.ts index 37089c4194..7256b2f62c 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.spec.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.spec.ts @@ -1,4 +1,9 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; import { expect, expectAsync } from '@skyux-sdk/testing'; import { @@ -291,7 +296,7 @@ describe('SkyCellEditorTextComponent', () => { }; }); - it('focuses on the input after it attaches to the DOM', () => { + it('focuses on the input after it attaches to the DOM', fakeAsync(() => { textEditorFixture.detectChanges(); const input = textEditorNativeElement.querySelector( @@ -300,13 +305,14 @@ describe('SkyCellEditorTextComponent', () => { spyOn(input, 'focus'); textEditorComponent.afterGuiAttached(); + tick(); expect(input).toBeVisible(); expect(input.focus).toHaveBeenCalled(); - }); + })); describe('cellStartedEdit is true', () => { - it('does not select the input value if Backspace triggers the edit', () => { + it('does not select the input value if Backspace triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.BACKSPACE, @@ -318,12 +324,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if Delete triggers the edit', () => { + it('does not select the input value if Delete triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.DELETE, @@ -335,12 +342,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(''); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if F2 triggers the edit', () => { + it('does not select the input value if F2 triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.F2, @@ -352,12 +360,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('selects the input value if Enter triggers the edit', () => { + it('selects the input value if Enter triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.ENTER, @@ -369,12 +378,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).toHaveBeenCalled(); - }); + })); - it('does not select the input value when a standard keyboard event triggers the edit', () => { + it('does not select the input value when a standard keyboard event triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: 'a', @@ -386,10 +396,11 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe('a'); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); }); describe('cellStartedEdit is false', () => { @@ -397,7 +408,7 @@ describe('SkyCellEditorTextComponent', () => { cellEditorParams.cellStartedEdit = false; }); - it('does not select the input value if Backspace triggers the edit', () => { + it('does not select the input value if Backspace triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.BACKSPACE, @@ -409,12 +420,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if Delete triggers the edit', () => { + it('does not select the input value if Delete triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.DELETE, @@ -426,12 +438,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value if F2 triggers the edit', () => { + it('does not select the input value if F2 triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.F2, @@ -443,12 +456,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('should not select the input value if Enter triggers the edit', () => { + it('should not select the input value if Enter triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: KeyCode.ENTER, @@ -460,12 +474,13 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); - it('does not select the input value when a standard keyboard event triggers the edit', () => { + it('does not select the input value when a standard keyboard event triggers the edit', fakeAsync(() => { textEditorComponent.agInit({ ...(cellEditorParams as ICellEditorParams), eventKey: 'a', @@ -477,10 +492,11 @@ describe('SkyCellEditorTextComponent', () => { const selectSpy = spyOn(input, 'select'); textEditorComponent.afterGuiAttached(); + tick(); expect(input.value).toBe(value); expect(selectSpy).not.toHaveBeenCalled(); - }); + })); }); }); diff --git a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.ts b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.ts index 67b0a0a8e5..d9312a1b44 100644 --- a/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.ts +++ b/libs/components/ag-grid/src/lib/modules/ag-grid/cell-editors/cell-editor-text/cell-editor-text.component.ts @@ -82,12 +82,17 @@ export class SkyAgGridCellEditorTextComponent * afterGuiAttached is called by agGrid after the editor is rendered in the DOM. Once it is attached the editor is ready to be focused on. */ public afterGuiAttached(): void { - if (this.input) { - this.input.nativeElement.focus(); - if (this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted) { - this.input.nativeElement.select(); + // AG Grid sets focus to the cell via setTimeout, and this queues the input to focus after that. + setTimeout(() => { + if (this.input) { + this.input.nativeElement.focus(); + if ( + this.#triggerType === SkyAgGridCellEditorInitialAction.Highlighted + ) { + this.input.nativeElement.select(); + } } - } + }); } /** From 08fe7e904e247640b0717be66c41e05d68d38b59 Mon Sep 17 00:00:00 2001 From: Blackbaud Sky Build User Date: Fri, 10 Jan 2025 11:13:52 -0500 Subject: [PATCH 02/12] feat(components/pages): create testing harness for action hub (#2962) (#3007) :cherries: Cherry picked from #2962 [feat(components/pages): create testing harness for action hub](https://github.com/blackbaud/skyux/pull/2962) [AB#2195350](https://dev.azure.com/blackbaud/f565481a-7bc9-4083-95d5-4f953da6d499/_workitems/edit/2195350) Co-authored-by: John White <750350+johnhwhite@users.noreply.github.com> --- apps/code-examples/project.json | 3 +- .../pages/action-hub/demo.component.html | 1 + .../pages/action-hub/demo.component.spec.ts | 110 +++++++++++ .../action-hub/settings-modal.component.html | 66 +++---- .../settings-modal.component.spec.ts | 70 +++++++ .../action-hub/settings-modal.component.ts | 2 - libs/components/pages/src/index.ts | 3 + .../link-list/link-list.component.html | 5 +- .../modal-link-list.component.html | 7 +- .../needs-attention.component.html | 4 +- .../needs-attention.component.ts | 4 + .../needs-attention/needs-attention.module.ts | 3 + .../action-hub/action-hub-harness-filters.ts | 7 + .../action-hub/action-hub-harness.spec.ts | 176 ++++++++++++++++++ .../modules/action-hub/action-hub-harness.ts | 91 +++++++++ .../link-list/link-list-harness.spec.ts | 35 +++- .../modules/link-list/link-list-harness.ts | 16 +- .../link-list-item-harness-filters.ts | 11 ++ .../link-list/link-list-item-harness.ts | 46 +++++ .../needs-attention-harness-filters.ts | 7 + .../needs-attention-harness.spec.ts | 103 ++++++++++ .../needs-attention-harness.ts | 58 ++++++ .../needs-attention-item-harness-filters.ts | 11 ++ .../needs-attention-item-harness.ts | 44 +++++ .../pages/testing/src/public-api.ts | 5 + 25 files changed, 838 insertions(+), 50 deletions(-) create mode 100644 apps/code-examples/src/app/code-examples/pages/action-hub/demo.component.spec.ts create mode 100644 apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.spec.ts create mode 100644 libs/components/pages/testing/src/modules/action-hub/action-hub-harness-filters.ts create mode 100644 libs/components/pages/testing/src/modules/action-hub/action-hub-harness.spec.ts create mode 100644 libs/components/pages/testing/src/modules/action-hub/action-hub-harness.ts create mode 100644 libs/components/pages/testing/src/modules/link-list/link-list-item-harness-filters.ts create mode 100644 libs/components/pages/testing/src/modules/link-list/link-list-item-harness.ts create mode 100644 libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness-filters.ts create mode 100644 libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.spec.ts create mode 100644 libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.ts create mode 100644 libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness-filters.ts create mode 100644 libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness.ts diff --git a/apps/code-examples/project.json b/apps/code-examples/project.json index 9759f16c45..86f0d73316 100644 --- a/apps/code-examples/project.json +++ b/apps/code-examples/project.json @@ -99,7 +99,8 @@ ], "styles": [ "libs/components/theme/src/lib/styles/sky.scss", - "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "libs/components/ag-grid/src/lib/styles/ag-grid-styles.scss" ], "scripts": [], "assets": [], diff --git a/apps/code-examples/src/app/code-examples/pages/action-hub/demo.component.html b/apps/code-examples/src/app/code-examples/pages/action-hub/demo.component.html index 0b5b2a02fd..127e29a74a 100644 --- a/apps/code-examples/src/app/code-examples/pages/action-hub/demo.component.html +++ b/apps/code-examples/src/app/code-examples/pages/action-hub/demo.component.html @@ -1,4 +1,5 @@ { + async function setupTest(): Promise<{ + actionHubHarness: SkyActionHubHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + const fixture = TestBed.createComponent(DemoComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + const actionHubHarness = await loader.getHarness( + SkyActionHubHarness.with({ + dataSkyId: 'action-hub', + }), + ); + + return { actionHubHarness, fixture, loader }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent, SkyModalTestingModule], + }); + }); + + it('should have correct page title', async () => { + const { actionHubHarness } = await setupTest(); + + expect(await actionHubHarness.getTitle()).toBe('Active accounts'); + }); + + it('should show needs-attention items', async () => { + const { actionHubHarness } = await setupTest(); + + expect( + await actionHubHarness + .getNeedsAttentionItems() + .then( + async (items) => + await Promise.all(items.map((item) => item.getText())), + ), + ).toEqual([ + '9 updates from portal', + '8 new messages from online donation', + '7 possible duplicates from constituent lists', + '6 updates from portal', + '5 new messages from online donation', + '4 possible duplicates from constituent lists', + '3 update from portal', + '2 new messages from online donation', + '1 possible duplicate from constituent lists', + ]); + }); + + it('should show recent links', async () => { + const { actionHubHarness } = await setupTest(); + + const linkListHarness = await actionHubHarness.getRecentLinks(); + const listItems = await linkListHarness.getListItems(); + expect(await Promise.all(listItems.map((item) => item.getText()))).toEqual([ + 'Recent 1', + 'Recent 2', + 'Recent 3', + 'Recent 4', + 'Recent 5', + ]); + }); + + it('should show related links', async () => { + const { actionHubHarness } = await setupTest(); + + const linkListHarness = await actionHubHarness.getRelatedLinks(); + const listItems = await linkListHarness.getListItems(); + expect(await Promise.all(listItems.map((item) => item.getText()))).toEqual([ + 'Link 1', + 'Link 2', + 'Link 3', + ]); + }); + + it('should show settings links', async () => { + const { actionHubHarness } = await setupTest(); + + const linkListHarness = await actionHubHarness.getSettingsLinks(); + const listItems = await linkListHarness.getListItems(); + expect(await Promise.all(listItems.map((item) => item.getText()))).toEqual([ + 'Number', + 'Color', + ]); + const modalController = TestBed.inject(SkyModalTestingController); + modalController.expectNone(); + await listItems[0].click(); + const app = TestBed.inject(ApplicationRef); + app.tick(); + await app.whenStable(); + modalController.expectCount(1); + modalController.expectOpen(SettingsModalComponent); + }); +}); diff --git a/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.html b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.html index 86f2890641..55469c6d8b 100644 --- a/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.html +++ b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.html @@ -1,49 +1,41 @@ - - -
+ + + @for (field of fields; track field) { @switch (title) { @case ('Color') { -
- - - - -
+ + + } @default { - + } }
} - -
- - - - -
+
+ + + + +
+ diff --git a/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.spec.ts b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.spec.ts new file mode 100644 index 0000000000..db15dec21d --- /dev/null +++ b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.spec.ts @@ -0,0 +1,70 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyModalHarness } from '@skyux/modals/testing'; +import { SkyActionHubHarness } from '@skyux/pages/testing'; + +import { DemoComponent } from './demo.component'; +import { MODAL_TITLE } from './modal-title-token'; + +describe('SettingsModalComponent', () => { + async function setupTest(): Promise<{ + actionHubHarness: SkyActionHubHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + rootLoader: HarnessLoader; + }> { + const fixture = TestBed.createComponent(DemoComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + const rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture); + const actionHubHarness = await loader.getHarness( + SkyActionHubHarness.with({ + dataSkyId: 'action-hub', + }), + ); + + return { actionHubHarness, rootLoader, fixture, loader }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent, NoopAnimationsModule], + providers: [ + { + provide: MODAL_TITLE, + useValue: 'Settings Modal Test', + }, + ], + }); + }); + + it('should show settings modal', async () => { + const { actionHubHarness, rootLoader } = await setupTest(); + const settingsLinksHarness = await actionHubHarness.getSettingsLinks(); + const settingsLinks = await settingsLinksHarness.getListItems(); + expect(settingsLinks).toHaveSize(2); + await settingsLinks[1].click(); + const modalHarness = await rootLoader.getHarness( + SkyModalHarness.with({ + dataSkyId: 'settings-modal', + }), + ); + expect(await modalHarness.getHeadingText()).toBe('Color'); + const consoleSpy = spyOn(console, 'log').and.stub(); + const modalElement = TestbedHarnessEnvironment.getNativeElement( + await modalHarness.host(), + ); + const submit = modalElement.querySelector('button[type="submit"]'); + expect(submit).toBeTruthy(); + expect(submit?.textContent?.trim()).toBe('Save'); + (submit as HTMLButtonElement).click(); + expect(consoleSpy).toHaveBeenCalledWith({ + 'Color 1': '', + 'Color 2': '', + 'Color 3': '', + 'Color 4': '', + 'Color 5': '', + }); + }); +}); diff --git a/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.ts b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.ts index 3ac65c4ce1..ab249e9aae 100644 --- a/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.ts +++ b/apps/code-examples/src/app/code-examples/pages/action-hub/settings-modal.component.ts @@ -7,7 +7,6 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { SkyColorpickerModule } from '@skyux/colorpicker'; -import { SkyIdModule } from '@skyux/core'; import { SkyInputBoxModule } from '@skyux/forms'; import { SkyModalInstance, SkyModalModule } from '@skyux/modals'; @@ -20,7 +19,6 @@ import { MODAL_TITLE } from './modal-title-token'; FormsModule, ReactiveFormsModule, SkyColorpickerModule, - SkyIdModule, SkyInputBoxModule, SkyModalModule, ], diff --git a/libs/components/pages/src/index.ts b/libs/components/pages/src/index.ts index a4b1ab1b1f..96b13a8143 100644 --- a/libs/components/pages/src/index.ts +++ b/libs/components/pages/src/index.ts @@ -5,6 +5,8 @@ export { SkyActionHubNeedsAttentionClickHandlerArgs, } from './lib/modules/action-hub/types/action-hub-needs-attention-click-handler'; export { SkyLinkListModule } from './lib/modules/link-list/link-list.module'; +export { SkyNeedsAttentionModule } from './lib/modules/needs-attention/needs-attention.module'; +export { SkyActionHubNeedsAttentionInput } from './lib/modules/action-hub/types/action-hub-needs-attention-input'; export { SkyPageLink } from './lib/modules/action-hub/types/page-link'; export { SkyPageLinksInput } from './lib/modules/action-hub/types/page-links-input'; export { SkyPageModalLink } from './lib/modules/action-hub/types/page-modal-link'; @@ -24,6 +26,7 @@ export { SkyLinkListComponent as λ13 } from './lib/modules/link-list/link-list. export { SkyLinkListItemComponent as λ14 } from './lib/modules/link-list/link-list-item.component'; export { SkyLinkListRecentlyAccessedComponent as λ15 } from './lib/modules/link-list-recently-accessed/link-list-recently-accessed.component'; export { SkyModalLinkListComponent as λ5 } from './lib/modules/modal-link-list/modal-link-list.component'; +export { SkyNeedsAttentionComponent as λ16 } from './lib/modules/needs-attention/needs-attention.component'; export { SkyPageHeaderActionsComponent as λ9 } from './lib/modules/page-header/page-header-actions.component'; export { SkyPageHeaderAlertsComponent as λ11 } from './lib/modules/page-header/page-header-alerts.component'; export { SkyPageHeaderAvatarComponent as λ10 } from './lib/modules/page-header/page-header-avatar.component'; diff --git a/libs/components/pages/src/lib/modules/link-list/link-list.component.html b/libs/components/pages/src/lib/modules/link-list/link-list.component.html index af902f4ad1..4fa091bdd2 100644 --- a/libs/components/pages/src/lib/modules/link-list/link-list.component.html +++ b/libs/components/pages/src/lib/modules/link-list/link-list.component.html @@ -8,15 +8,16 @@ @for (link of linksArray(); track link) {
  • @if (link | linkAs: 'skyHref') { - + {{ link.label }} } @else if (link | linkAs: 'href') { - + {{ link.label }} } @else if (link.permalink.route) { + {{ link.label }} } @else if (link | linkAs: 'href') { - + {{ link.label }} } @else if (link.permalink && link.permalink.route) { diff --git a/libs/components/pages/src/lib/modules/needs-attention/needs-attention.component.html b/libs/components/pages/src/lib/modules/needs-attention/needs-attention.component.html index 3be258a52a..68f7caf0a6 100644 --- a/libs/components/pages/src/lib/modules/needs-attention/needs-attention.component.html +++ b/libs/components/pages/src/lib/modules/needs-attention/needs-attention.component.html @@ -5,10 +5,10 @@

    - @if (items?.length === 0) { + @if (!items?.length) { {{ 'sky_action_hub_needs_attention_empty' | skyLibResources }} } @else { -
      +
        @for (item of items; track item; let isLast = $last) {
      • `, + imports: [SkyActionHubModule], +}) +class TestComponent { + public readonly needsAttention = input(); + public readonly recentLinks = input(); + public readonly relatedLinks = input(); + public readonly settingsLinks = input(); + public readonly title = input('Title'); +} + +//#endregion Test component + +describe('SkyActionHubHarness', () => { + async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + harness: SkyActionHubHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + TestBed.configureTestingModule({ + imports: [TestComponent], + }); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + let harness: SkyActionHubHarness; + + if (options.dataSkyId) { + harness = await loader.getHarness( + SkyActionHubHarness.with({ + dataSkyId: options.dataSkyId, + }), + ); + } else { + harness = await loader.getHarness(SkyActionHubHarness); + } + + return { harness, fixture, loader }; + } + + it('should return the title', async () => { + const { harness } = await setupTest({ + dataSkyId: 'action-hub', + }); + + await expectAsync(harness.getTitle()).toBeResolvedTo('Title'); + }); + + it('should return the needs attention block', async () => { + const { fixture, harness, loader } = await setupTest(); + fixture.componentRef.setInput('needsAttention', [ + { + title: 'First item', + click: jasmine.createSpy('click'), + }, + { + title: 'Second item', + click: jasmine.createSpy('click'), + }, + ]); + fixture.detectChanges(); + + await expectAsync( + harness + .getNeedsAttentionBlock() + .then((needsAttention) => needsAttention.getTitle()), + ).toBeResolvedTo('Needs attention'); + await expectAsync( + harness + .getNeedsAttentionItems() + .then((needsAttentionItems) => + Promise.all(needsAttentionItems.map((item) => item.getText())), + ), + ).toBeResolvedTo(['First item', 'Second item']); + const item = await loader.getHarness( + SkyNeedsAttentionItemHarness.with({ + text: 'First item', + }), + ); + await expectAsync(item.click()).toBeResolved(); + }); + + it('should return the links', async () => { + const { fixture, harness } = await setupTest(); + fixture.componentRef.setInput('recentLinks', [ + { + label: 'Recent Link', + permalink: { + url: '#', + }, + lastAccessed: new Date(), + }, + ]); + fixture.componentRef.setInput('relatedLinks', [ + { + label: 'Related Link', + permalink: { + url: '#', + }, + }, + ]); + fixture.componentRef.setInput('settingsLinks', [ + { + label: 'Settings Link', + permalink: { + url: '#', + }, + }, + ]); + fixture.detectChanges(); + fixture.debugElement + .queryAll(By.css('sky-page-links a')) + .forEach((element) => { + element.nativeElement.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + + await expectAsync( + harness.getRecentLinks().then((links) => links.isVisible()), + ).toBeResolvedTo(true); + await expectAsync( + harness.getRecentLinks().then( + async (links) => + await ( + await links.getListItems({ + text: 'Recent Link', + }) + )[0].click(), + ), + ).toBeResolved(); + + await expectAsync( + harness.getRelatedLinks().then((links) => links.isVisible()), + ).toBeResolvedTo(true); + await expectAsync( + harness + .getRelatedLinks() + .then(async (links) => await (await links.getListItems())[0].click()), + ).toBeResolved(); + + await expectAsync( + harness.getSettingsLinks().then((links) => links.isVisible()), + ).toBeResolvedTo(true); + }); +}); diff --git a/libs/components/pages/testing/src/modules/action-hub/action-hub-harness.ts b/libs/components/pages/testing/src/modules/action-hub/action-hub-harness.ts new file mode 100644 index 0000000000..6a583c7ac4 --- /dev/null +++ b/libs/components/pages/testing/src/modules/action-hub/action-hub-harness.ts @@ -0,0 +1,91 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyLinkListHarness } from '../link-list/link-list-harness'; +import { SkyNeedsAttentionHarness } from '../needs-attention/needs-attention-harness'; +import { SkyNeedsAttentionItemHarness } from '../needs-attention/needs-attention-item-harness'; +import { SkyNeedsAttentionItemHarnessFilters } from '../needs-attention/needs-attention-item-harness-filters'; +import { SkyPageHeaderHarness } from '../page-header/page-header-harness'; + +import { SkyActionHubHarnessFilters } from './action-hub-harness-filters'; + +/** + * Harness for interacting with an action hub component in tests. + */ +export class SkyActionHubHarness extends SkyComponentHarness { + public static hostSelector = 'sky-action-hub'; + + readonly #pageHeader = this.locatorFor(SkyPageHeaderHarness); + readonly #needsAttention = this.locatorFor(SkyNeedsAttentionHarness); + readonly #relatedLinks = this.locatorFor( + SkyLinkListHarness.with({ + selector: 'sky-page-links > sky-link-list', + }), + ); + readonly #recentLinks = this.locatorFor( + SkyLinkListHarness.with({ + selector: 'sky-page-links > sky-link-list-recently-accessed', + }), + ); + readonly #settingsLinks = this.locatorFor( + SkyLinkListHarness.with({ + selector: 'sky-page-links > sky-modal-link-list', + }), + ); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyLinkListHarness` that meets certain criteria. + */ + public static with( + filters: SkyActionHubHarnessFilters, + ): HarnessPredicate { + return SkyActionHubHarness.getDataSkyIdPredicate(filters); + } + + /** + * Gets the title of the action hub. + */ + public async getTitle(): Promise { + return await (await this.#pageHeader()).getPageTitle(); + } + + /** + * Get the testing harness for the needs attention block. + */ + public async getNeedsAttentionBlock(): Promise { + return await this.#needsAttention(); + } + + /** + * Get the testing harnesses for items within the needs attention block. + */ + public async getNeedsAttentionItems( + filter: SkyNeedsAttentionItemHarnessFilters = {}, + ): Promise { + return await this.locatorForAll( + SkyNeedsAttentionItemHarness.with(filter), + )(); + } + + /** + * Get the testing harness for the related links block. + */ + public async getRelatedLinks(): Promise { + return await this.#relatedLinks(); + } + + /** + * Get the testing harness for the recent links block. + */ + public async getRecentLinks(): Promise { + return await this.#recentLinks(); + } + + /** + * Get the testing harness for the settings links block. + */ + public async getSettingsLinks(): Promise { + return await this.#settingsLinks(); + } +} diff --git a/libs/components/pages/testing/src/modules/link-list/link-list-harness.spec.ts b/libs/components/pages/testing/src/modules/link-list/link-list-harness.spec.ts index c92dca35fa..2e80154f66 100644 --- a/libs/components/pages/testing/src/modules/link-list/link-list-harness.spec.ts +++ b/libs/components/pages/testing/src/modules/link-list/link-list-harness.spec.ts @@ -5,11 +5,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SkyLinkListModule, SkyPageLinksInput } from '@skyux/pages'; import { SkyLinkListHarness } from './link-list-harness'; +import { SkyLinkListItemHarness } from './link-list-item-harness'; //#region Test component @Component({ selector: 'sky-link-list-test', - template: `(true); public readonly inputLinks = input(); } + //#endregion Test component describe('Link list harness', () => { @@ -86,4 +88,35 @@ describe('Link list harness', () => { fixture.componentRef.setInput('showLinks', false); await expectAsync(harness.isVisible()).toBeResolvedTo(false); }); + + it('should retrieve list items', async () => { + const { fixture, loader } = await setupTest(); + fixture.componentRef.setInput('inputLinks', [ + { + label: 'Test Link 1', + permalink: { + url: '#', + }, + }, + { + label: 'Test Link 2', + permalink: { + url: '#', + }, + }, + ]); + fixture.detectChanges(); + + const links = await loader.getAllHarnesses(SkyLinkListItemHarness); + expect(links).toHaveSize(2); + await expectAsync( + loader + .getHarness( + SkyLinkListItemHarness.with({ + text: 'Test Link 2', + }), + ) + .then((harness) => harness.getText()), + ).toBeResolvedTo('Test Link 2'); + }); }); diff --git a/libs/components/pages/testing/src/modules/link-list/link-list-harness.ts b/libs/components/pages/testing/src/modules/link-list/link-list-harness.ts index 0e37ecfaf8..5cd94e734e 100644 --- a/libs/components/pages/testing/src/modules/link-list/link-list-harness.ts +++ b/libs/components/pages/testing/src/modules/link-list/link-list-harness.ts @@ -3,6 +3,8 @@ import { SkyComponentHarness } from '@skyux/core/testing'; import { SkyWaitHarness } from '@skyux/indicators/testing'; import { SkyLinkListHarnessFilters } from './link-list-harness-filters'; +import { SkyLinkListItemHarness } from './link-list-item-harness'; +import { SkyLinkListItemHarnessFilters } from './link-list-item-harness-filters'; /** * Harness for interacting with a link list component in tests. @@ -11,7 +13,8 @@ export class SkyLinkListHarness extends SkyComponentHarness { /** * @internal */ - public static hostSelector = 'sky-link-list'; + public static hostSelector = + 'sky-link-list, sky-link-list-recently-accessed, sky-modal-link-list'; #getHeading = this.locatorFor('h2.sky-font-heading-4'); #getList = this.locatorFor('ul.sky-link-list'); @@ -50,7 +53,16 @@ export class SkyLinkListHarness extends SkyComponentHarness { * Gets the status of the wait indicator. */ public async isWaiting(): Promise { - const waitHarness = await (await this.locatorFor(SkyWaitHarness))(); + const waitHarness = await this.locatorFor(SkyWaitHarness)(); return await waitHarness.isWaiting(); } + + /** + * Gets the link list items. + */ + public async getListItems( + filter: SkyLinkListItemHarnessFilters = {}, + ): Promise { + return await this.locatorForAll(SkyLinkListItemHarness.with(filter))(); + } } diff --git a/libs/components/pages/testing/src/modules/link-list/link-list-item-harness-filters.ts b/libs/components/pages/testing/src/modules/link-list/link-list-item-harness-filters.ts new file mode 100644 index 0000000000..c76f8cc6ff --- /dev/null +++ b/libs/components/pages/testing/src/modules/link-list/link-list-item-harness-filters.ts @@ -0,0 +1,11 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of SkyLinkListItemHarness instances. + */ +export interface SkyLinkListItemHarnessFilters extends SkyHarnessFilters { + /** + * Only find instances whose text content matches the given value. + */ + text?: string; +} diff --git a/libs/components/pages/testing/src/modules/link-list/link-list-item-harness.ts b/libs/components/pages/testing/src/modules/link-list/link-list-item-harness.ts new file mode 100644 index 0000000000..1e04c67b74 --- /dev/null +++ b/libs/components/pages/testing/src/modules/link-list/link-list-item-harness.ts @@ -0,0 +1,46 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyLinkListItemHarnessFilters } from './link-list-item-harness-filters'; + +/** + * Harness for interacting with a linked list item in tests. + */ +export class SkyLinkListItemHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = + 'a.sky-link-list-item, button.sky-link-list-item'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyLinkListItemHarness` that meets certain criteria. + */ + public static with( + filters: SkyLinkListItemHarnessFilters, + ): HarnessPredicate { + return SkyLinkListItemHarness.getDataSkyIdPredicate(filters).addOption( + 'text', + filters.text, + async (harness, text) => { + const itemText = await harness.getText(); + return await HarnessPredicate.stringMatches(itemText, text); + }, + ); + } + + /** + * Gets the text content of the item. + */ + public async getText(): Promise { + return await (await this.host()).text(); + } + + /** + * Clicks the item. + */ + public async click(): Promise { + return await (await this.host()).click(); + } +} diff --git a/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness-filters.ts b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness-filters.ts new file mode 100644 index 0000000000..f7282f9a8d --- /dev/null +++ b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness-filters.ts @@ -0,0 +1,7 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of SkyNeedsAttentionHarness instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyNeedsAttentionHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.spec.ts b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.spec.ts new file mode 100644 index 0000000000..60dccae01b --- /dev/null +++ b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.spec.ts @@ -0,0 +1,103 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + SkyActionHubNeedsAttention, + SkyNeedsAttentionModule, +} from '@skyux/pages'; + +import { SkyNeedsAttentionHarness } from './needs-attention-harness'; +import { SkyNeedsAttentionItemHarness } from './needs-attention-item-harness'; + +//#region Test component +@Component({ + standalone: true, + selector: 'sky-needs-attention-test', + template: ` `, + imports: [SkyNeedsAttentionModule], +}) +class TestComponent { + public readonly items = input(); +} + +//#endregion Test component + +describe('Needs attention harness', () => { + async function setupTest( + options: { + dataSkyId?: string; + items?: SkyActionHubNeedsAttention[] | undefined; + } = {}, + ): Promise<{ + harness: SkyNeedsAttentionHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + TestBed.configureTestingModule({ + imports: [TestComponent], + }); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + if (options.items) { + fixture.componentRef.setInput('items', options.items); + } + + let harness: SkyNeedsAttentionHarness; + + if (options.dataSkyId) { + harness = await loader.getHarness( + SkyNeedsAttentionHarness.with({ + dataSkyId: options.dataSkyId, + }), + ); + } else { + harness = await loader.getHarness(SkyNeedsAttentionHarness); + } + + return { harness, fixture, loader }; + } + + it('should return the heading text', async () => { + const { harness, fixture } = await setupTest({ + dataSkyId: 'needs-attention', + }); + fixture.detectChanges(); + + await expectAsync(harness.getTitle()).toBeResolvedTo('Needs attention'); + }); + + it('should return the empty list text', async () => { + const { harness, fixture } = await setupTest(); + fixture.detectChanges(); + + await expectAsync(harness.hasItems()).toBeResolvedTo(false); + await expectAsync(harness.getEmptyListText()).toBeResolvedTo( + 'No issues currently need attention', + ); + }); + + it('should return the list items', async () => { + const { harness, fixture, loader } = await setupTest({ + items: [ + { + title: 'Item 1', + permalink: { + url: 'https://example.com', + }, + }, + ], + }); + fixture.detectChanges(); + + await expectAsync(harness.hasItems()).toBeResolvedTo(true); + await expectAsync(harness.getEmptyListText()).toBeResolvedTo(undefined); + const items = await loader.getAllHarnesses(SkyNeedsAttentionItemHarness); + expect(items).toHaveSize(1); + await expectAsync(items[0].getText()).toBeResolvedTo('Item 1'); + }); +}); diff --git a/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.ts b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.ts new file mode 100644 index 0000000000..ec4458a861 --- /dev/null +++ b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-harness.ts @@ -0,0 +1,58 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; +import { SkyBoxHarness } from '@skyux/layout/testing'; + +import { SkyNeedsAttentionHarnessFilters } from './needs-attention-harness-filters'; + +/** + * Harness for interacting with the needs-attention component in tests. + */ +export class SkyNeedsAttentionHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-needs-attention'; + + readonly #boxHarness = this.locatorFor(SkyBoxHarness); + readonly #getContent = this.locatorFor('sky-box-content'); + readonly #getList = this.locatorFor('ul.sky-needs-attention-list'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyNeedsAttentionHarness` that meets certain criteria. + */ + public static with( + filters: SkyNeedsAttentionHarnessFilters, + ): HarnessPredicate { + return SkyNeedsAttentionHarness.getDataSkyIdPredicate(filters); + } + + /** + * Gets the component's heading text. If there are no links, this will return `undefined`. + */ + public async getTitle(): Promise { + return await (await this.#boxHarness()).getHeadingText(); + } + + /** + * Gets the text from an empty list. If there are items in the list, this will return `undefined`. + */ + public async getEmptyListText(): Promise { + return await this.hasItems().then(async (hasItems) => { + if (hasItems) { + return undefined; + } + return (await (await this.#getContent()).text()).trim(); + }); + } + + /** + * Whether the link list is showing a list of links. + */ + public async hasItems(): Promise { + return await this.#getList().then( + () => true, + () => false, + ); + } +} diff --git a/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness-filters.ts b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness-filters.ts new file mode 100644 index 0000000000..69d2560e84 --- /dev/null +++ b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness-filters.ts @@ -0,0 +1,11 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of SkyNeedsAttentionItemHarness instances. + */ +export interface SkyNeedsAttentionItemHarnessFilters extends SkyHarnessFilters { + /** + * Only find instances whose text content matches the given value. + */ + text?: string; +} diff --git a/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness.ts b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness.ts new file mode 100644 index 0000000000..abe0f15c29 --- /dev/null +++ b/libs/components/pages/testing/src/modules/needs-attention/needs-attention-item-harness.ts @@ -0,0 +1,44 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyNeedsAttentionItemHarnessFilters } from './needs-attention-item-harness-filters'; + +/** + * Harness for interacting with a needs attention item in tests. + */ +export class SkyNeedsAttentionItemHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = + 'li.sky-needs-attention-item-wrapper > a.sky-needs-attention-item, li.sky-needs-attention-item-wrapper > button.sky-needs-attention-item'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyNeedsAttentionItemHarness` that meets certain criteria. + */ + public static with( + filters: SkyNeedsAttentionItemHarnessFilters, + ): HarnessPredicate { + return SkyNeedsAttentionItemHarness.getDataSkyIdPredicate( + filters, + ).addOption('text', filters.text, async (harness, text) => { + const itemText = await harness.getText(); + return await HarnessPredicate.stringMatches(itemText, text); + }); + } + + /** + * Gets the text content of the item. + */ + public async getText(): Promise { + return await (await this.host()).text(); + } + + /** + * Clicks the item. + */ + public async click(): Promise { + return await (await this.host()).click(); + } +} diff --git a/libs/components/pages/testing/src/public-api.ts b/libs/components/pages/testing/src/public-api.ts index e6ed9aeb09..40fc8ea576 100644 --- a/libs/components/pages/testing/src/public-api.ts +++ b/libs/components/pages/testing/src/public-api.ts @@ -1,5 +1,10 @@ +export { SkyActionHubHarness } from './modules/action-hub/action-hub-harness'; +export { SkyActionHubHarnessFilters } from './modules/action-hub/action-hub-harness-filters'; export { SkyLinkListHarness } from './modules/link-list/link-list-harness'; export { SkyLinkListHarnessFilters } from './modules/link-list/link-list-harness-filters'; +export { SkyLinkListItemHarness } from './modules/link-list/link-list-item-harness'; +export { SkyLinkListItemHarnessFilters } from './modules/link-list/link-list-item-harness-filters'; +export { SkyNeedsAttentionHarness } from './modules/needs-attention/needs-attention-harness'; export { SkyPageHeaderHarness } from './modules/page-header/page-header-harness'; export { SkyPageHeaderHarnessFilters } from './modules/page-header/page-header-harness-filters'; export { SkyPageHarness } from './modules/page/page-harness'; From ef503a3ba166733026c627040001bf0caebf57e3 Mon Sep 17 00:00:00 2001 From: Erika McVey <50454925+Blackbaud-ErikaMcVey@users.noreply.github.com> Date: Fri, 10 Jan 2025 12:33:30 -0500 Subject: [PATCH 03/12] fix(components/lists): update filter visual tests (#3010) --- .../src/app/filter/filter.component.html | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/e2e/lists-storybook/src/app/filter/filter.component.html b/apps/e2e/lists-storybook/src/app/filter/filter.component.html index a591ff0447..9d71305aa4 100644 --- a/apps/e2e/lists-storybook/src/app/filter/filter.component.html +++ b/apps/e2e/lists-storybook/src/app/filter/filter.component.html @@ -13,6 +13,20 @@

        Filter button

        [showButtonText]="false" (filterButtonClick)="filtersActive = !filtersActive" /> + {{ ' ' }} + + {{ ' ' }} +

        Filter button with text

        @@ -29,6 +43,20 @@

        Filter button with text

        [showButtonText]="true" (filterButtonClick)="filtersActive = !filtersActive" /> + {{ ' ' }} + + {{ ' ' }} +

        Filter summary

        @@ -61,7 +89,7 @@

        Inline filter

        - +