From 33df82bf5fb74a045680fcc35e4c7de1c4f97753 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 15 Jun 2026 10:23:38 +0300 Subject: [PATCH 01/32] fix(grid): fix zone.onStable patterns broken in zoneless change detection --- .../grids/grid/src/grid-base.directive.ts | 54 ++- .../grids/grid/src/grid.zoneless.spec.ts | 369 ++++++++++++++++++ .../pivot-grid/src/pivot-grid.component.ts | 10 +- 3 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 9201378861b..bb08c4fac52 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -4188,9 +4188,13 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasColumnsToAutosize) { this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this.autoSizeColumnsInView(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this.autoSizeColumnsInView(); + }); + } }); } // Window resize observer not needed because when you resize the window element the tbody container always resize so @@ -4703,10 +4707,15 @@ export abstract class IgxGridBaseDirective implements GridType, // reset auto-size and calculate it again. this._columns.forEach(x => x.autoSize = undefined); this.resetCaches(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this.cdr.detectChanges(); this.autoSizeColumnsInView(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this.cdr.detectChanges(); + this.autoSizeColumnsInView(); + }); + } } /** @@ -6399,7 +6408,7 @@ export abstract class IgxGridBaseDirective implements GridType, const tmplId = args.context.templateID.type; const index = args.context.index; args.view.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + const restoreState = () => { const row = tmplId === 'dataRow' ? this.gridAPI.get_row_by_index(index) : null; const summaryRow = tmplId === 'summaryRow' ? this.summariesRowList.find((sr) => sr.dataRowIndex === index) : null; if (row && row instanceof IgxRowDirective) { @@ -6407,7 +6416,12 @@ export abstract class IgxGridBaseDirective implements GridType, } else if (summaryRow) { this._restoreVirtState(summaryRow); } - }); + }; + if (this.isZonelessChangeDetection()) { + restoreState(); + } else { + this.zone.onStable.pipe(first()).subscribe(restoreState); + } } } @@ -7113,9 +7127,13 @@ export abstract class IgxGridBaseDirective implements GridType, this.resetCaches(recalcFeatureWidth); if (this.hasColumnsToAutosize) { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this._autoSizeColumnsNotify.next(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this._autoSizeColumnsNotify.next(); + }); + } } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7830,14 +7848,20 @@ export abstract class IgxGridBaseDirective implements GridType, this._horizontalForOfs.forEach(vfor => vfor.onHScroll(scrollLeft)); this.cdr.markForCheck(); - this.zone.run(() => { - this.zone.onStable.pipe(first()).subscribe(() => { - this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); - requestAnimationFrame(() => { - this.autoSizeColumnsInView(); - }); + const emitChunkLoad = () => { + this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); + requestAnimationFrame(() => { + this.autoSizeColumnsInView(); }); - }); + }; + if (this.isZonelessChangeDetection()) { + this.cdr.detectChanges(); + emitChunkLoad(); + } else { + this.zone.run(() => { + this.zone.onStable.pipe(first()).subscribe(emitChunkLoad); + }); + } if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); } diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts new file mode 100644 index 00000000000..ae112719085 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -0,0 +1,369 @@ +/** + * Zoneless change-detection regression tests for IgxGrid. + * + * Each describe block resets the TestBed and adds provideZonelessChangeDetection() + * so tests run exactly as a consumer app would when Zone.js CD scheduling is absent. + * + * Constraint: after the action under test, fixture.detectChanges() is NOT called. + * Rendered updates must appear via the Angular zoneless scheduler (markForCheck + + * PendingTasks) confirmed with fixture.whenStable(), or via observable / event spies. + * + * Patterns covered + * ──────────────── + * 1. Initial render – grid displays rows on first detectChanges() + * 2. Async data change – sort / filter trigger notifyChanges() → markForCheck() + * → zoneless scheduler → ngDoCheck() → detectChanges() + * 3. Browser callback – filteringDone emitted from a requestAnimationFrame callback + * 4. Horizontal scroll – parentVirtDir.chunkLoad emitted after hScroll event + * (broken: zone.onStable never fires in NoopNgZone) + * 5. fit-content column API – recalculateAutoSizes() and calculateGridSizes() must + * reach autoSizeColumnsInView() without zone.onStable + * + * Patterns NOT covered here (separate PRs) + * ───────────────────────────────────────── + * - Pivot grid auto-size (fixed in pivot-grid.component.ts but no test added here) + * - IgxForOfDirective/IgxGridForOfDirective zone.onStable fixes — covered by vkombov/fix-17280 + * - IntersectionObserver zone.run fix in grid-base.directive.ts — covered by vkombov/fix-17280 + * - Row editing overlay position (zone.onStable in RowEditPositionStrategy) + * - cachedViewLoaded() virt-state restoration after view recycling during H-scroll + */ + +import { Component, ViewChild, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { SortingDirection, IgxStringFilteringOperand } from 'igniteui-angular/core'; +import { IgxGridComponent } from './grid.component'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; + +// ─── Reusable test host components ────────────────────────────────────────── + +/** Simple grid with ID / Name / LastName columns; data from personIDNameRegionData (7 rows). */ +@Component({ + template: ` + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessSimpleGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = SampleTestData.personIDNameRegionData(); +} + +/** + * Wide grid (5 × 200 px columns in a 400 px container) that forces horizontal + * virtualization so we can verify parentVirtDir.chunkLoad after scrolling. + */ +@Component({ + template: ` + + + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessWideGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = Array.from({ length: 5 }, (_, i) => ({ + col0: i, col1: i * 2, col2: i * 3, col3: i * 4, col4: i * 5 + })); +} + +/** + * Grid with fit-content columns so hasColumnsToAutosize is true. + * Used to exercise recalculateAutoSizes() and calculateGridSizes() autosize paths. + */ +@Component({ + template: ` + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessAutoSizeGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = SampleTestData.personIDNameRegionData(); +} + +// ─── Test suite ───────────────────────────────────────────────────────────── + +describe('IgxGrid - Zoneless Change Detection #grid', () => { + + // ── Pattern 1 & 2: initial render + async data changes ────────────────── + + describe('Basic rendering and async data changes', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + it('should render data rows on initial detectChanges()', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(7, 'expected all 7 data rows to be rendered'); + }); + + it('should update row order after sort() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + // personIDNameRegionData has IDs [2,1,6,7,5,4,3]; ascending sort → 1 first + grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false }); + // No fixture.detectChanges() here — the zoneless scheduler must run it + await fix.whenStable(); + + const firstRowCells = fix.debugElement + .query(By.css('igx-grid-row')) + .queryAll(By.css('igx-grid-cell')); + expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('1'); + }); + + it('should update row order after sort() desc without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + await fix.whenStable(); + + const firstRowCells = fix.debugElement + .query(By.css('igx-grid-row')) + .queryAll(By.css('igx-grid-cell')); + // highest ID is 7 + expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('7'); + }); + + it('should reduce visible rows after filter() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + // personIDNameRegionData has 2 rows named "Rick" + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(2, 'expected exactly 2 "Rick" rows after filter'); + }); + + it('should restore full row count after clearFilter() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + await fix.whenStable(); + + grid.clearFilter('Name'); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(7, 'expected all rows restored after clearFilter'); + }); + }); + + // ── Pattern 3: browser callback (requestAnimationFrame) ───────────────── + + describe('filteringDone event (requestAnimationFrame callback)', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + it('should emit filteringDone after filter() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const emittedArgs: any[] = []; + grid.filteringDone.subscribe(args => emittedArgs.push(args)); + + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + // requestAnimationFrame is treated as a macrotask in fakeAsync; tick flushes it + tick(16); + + expect(emittedArgs.length).toBe(1, 'filteringDone must emit exactly once'); + expect(emittedArgs[0]).toBeTruthy(); + })); + + it('should emit filteringDone after clearFilter() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + tick(16); + + const emittedArgs: any[] = []; + grid.filteringDone.subscribe(args => emittedArgs.push(args)); + + grid.clearFilter('Name'); + tick(16); + + expect(emittedArgs.length).toBe(1, 'filteringDone must emit after clearFilter'); + })); + }); + + // ── Pattern 4: horizontal-scroll chunkLoad (zone.onStable issue) ──────── + + describe('Horizontal scroll – parentVirtDir.chunkLoad emission', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessWideGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + /** + * Regression: in NoopNgZone (zoneless), zone.onStable never emits. + * horizontalScrollHandler() gated parentVirtDir.chunkLoad.emit() behind + * zone.onStable.pipe(first()).subscribe(), so the event was never raised + * after a horizontal scroll in a zoneless consumer app. + * + * Fix: apply the same isZonelessChangeDetection() guard used in + * verticalScrollHandler() and call emit() directly. + */ + it('should emit parentVirtDir.chunkLoad after horizontal scroll in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessWideGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const chunkLoadSpy = jasmine.createSpy('parentVirtDir.chunkLoad'); + grid.parentVirtDir.chunkLoad.subscribe(chunkLoadSpy); + + // Trigger the horizontal scroll handler the same way the real scroller does + const hScroller = grid.headerContainer.getScroll(); + hScroller.scrollLeft = 300; + hScroller.dispatchEvent(new Event('scroll')); + tick(100); + + expect(chunkLoadSpy).toHaveBeenCalled(); + })); + + it('should render updated column data after horizontal scroll in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessWideGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + + // Wait for chunkLoad as the reliable signal that virtualization has settled + let chunkLoaded = false; + grid.parentVirtDir.chunkLoad.subscribe(() => { chunkLoaded = true; }); + + const hScroller = grid.headerContainer.getScroll(); + hScroller.scrollLeft = 400; + hScroller.dispatchEvent(new Event('scroll')); + tick(100); + + expect(chunkLoaded).toBe(true, 'chunkLoad must emit so the grid knows columns shifted'); + })); + }); + + // ── Pattern 5: fit-content column auto-sizing ──────────────────────────── + + describe('fit-content column auto-sizing', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessAutoSizeGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + /** + * Regression: recalculateAutoSizes() first resets col.autoSize to undefined, + * then gates the re-measurement behind zone.onStable.pipe(first()).subscribe(). + * In NoopNgZone that subscription never fires, so the method silently does nothing. + * + * Fix: detect zoneless with isZonelessChangeDetection() and call + * cdr.detectChanges() + autoSizeColumnsInView() directly. + * + * Note on hasColumnsToAutosize: after the initial render ChromeHeadless measures + * real header widths > 0, so col.autoSize becomes a number and col.width returns + * "Npx", making hasColumnsToAutosize false. recalculateAutoSizes() itself resets + * col.autoSize to undefined before calling the measurement — we only need to verify + * that the measurement is actually invoked, so we spy on the protected method. + */ + it('recalculateAutoSizes() should call autoSizeColumnsInView() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); + + grid.recalculateAutoSizes(); + tick(16); + + expect(spy).toHaveBeenCalled(); + })); + + /** + * Regression: _zoneBegoneListeners() subscribes to headerContainer.dataChanged and + * gates autoSizeColumnsInView() behind zone.onStable.pipe(first()).subscribe(). + * In NoopNgZone that subscription never fires, so columns are never re-measured + * when the header virtual scroll data changes (column visibility toggle, H-scroll). + * + * Fix: detect zoneless with isZonelessChangeDetection() and call + * autoSizeColumnsInView() directly after detectChanges(). + * + * Setup: hide then show a column, which causes headerContainer.dataChanged to emit. + * The spy is placed between the two visibility changes so only the "show" emission + * is captured. + */ + it('toggling a fit-content column visible should call autoSizeColumnsInView() via dataChanged in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const col = grid.getColumnByName('LastName'); + col.hidden = true; + fix.detectChanges(); + tick(16); + + const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); + + // Making the column visible emits headerContainer.dataChanged which must reach + // autoSizeColumnsInView() without zone.onStable in between. + col.hidden = false; + tick(16); + + expect(spy).toHaveBeenCalled(); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts index aebbdbb6831..9ad7dbe2e60 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -2153,11 +2153,17 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + requestAnimationFrame(() => { + this.autoSizeDimensionsInView(); + }); + }); + } } } From a3c606c5914d573d6f3ab0072c417d9a451f7ff7 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 15 Jun 2026 12:51:56 +0300 Subject: [PATCH 02/32] chore(*): fix a lint error --- .../igniteui-angular/grids/grid/src/grid.zoneless.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts index ae112719085..a58b84ff333 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -282,7 +282,9 @@ describe('IgxGrid - Zoneless Change Detection #grid', () => { // Wait for chunkLoad as the reliable signal that virtualization has settled let chunkLoaded = false; - grid.parentVirtDir.chunkLoad.subscribe(() => { chunkLoaded = true; }); + grid.parentVirtDir.chunkLoad.subscribe(() => { + chunkLoaded = true; + }); const hScroller = grid.headerContainer.getScroll(); hScroller.scrollLeft = 400; From f65ec9c8ff16151fa5b0df98d08e91d8f3720373 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 26 Jun 2026 19:50:35 +0300 Subject: [PATCH 03/32] fix(grid): improve zoneless scheduling support --- .../grids/grid/src/grid-base.directive.ts | 50 ++++++--------- .../grids/grid/src/grid.zoneless.spec.ts | 2 +- .../pivot-grid/src/pivot-grid.component.ts | 12 +--- .../grids/pivot-grid/src/pivot-grid.spec.ts | 61 +++++++++++++++++++ 4 files changed, 85 insertions(+), 40 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 9e9f1ae4bad..be061412146 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -30,7 +30,8 @@ import { ViewContainerRef, DOCUMENT, inject, - InjectionToken + InjectionToken, + ɵNoopNgZone as NoopNgZone } from '@angular/core'; import { areEqualArrays, @@ -4165,13 +4166,9 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasColumnsToAutosize) { this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); - if (this.isZonelessChangeDetection()) { + this.runAfterZoneStable(() => { this.autoSizeColumnsInView(); - } else { - this.zone.onStable.pipe(first()).subscribe(() => { - this.autoSizeColumnsInView(); - }); - } + }); }); } // Window resize observer not needed because when you resize the window element the tbody container always resize so @@ -4684,15 +4681,10 @@ export abstract class IgxGridBaseDirective implements GridType, // reset auto-size and calculate it again. this._columns.forEach(x => x.autoSize = undefined); this.resetCaches(); - if (this.isZonelessChangeDetection()) { + this.runAfterZoneStable(() => { this.cdr.detectChanges(); this.autoSizeColumnsInView(); - } else { - this.zone.onStable.pipe(first()).subscribe(() => { - this.cdr.detectChanges(); - this.autoSizeColumnsInView(); - }); - } + }); } /** @@ -6394,11 +6386,7 @@ export abstract class IgxGridBaseDirective implements GridType, this._restoreVirtState(summaryRow); } }; - if (this.isZonelessChangeDetection()) { - restoreState(); - } else { - this.zone.onStable.pipe(first()).subscribe(restoreState); - } + this.runAfterZoneStable(restoreState); } } @@ -7104,13 +7092,9 @@ export abstract class IgxGridBaseDirective implements GridType, this.resetCaches(recalcFeatureWidth); if (this.hasColumnsToAutosize) { this.cdr.detectChanges(); - if (this.isZonelessChangeDetection()) { + this.runAfterZoneStable(() => { this._autoSizeColumnsNotify.next(); - } else { - this.zone.onStable.pipe(first()).subscribe(() => { - this._autoSizeColumnsNotify.next(); - }); - } + }); } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7769,10 +7753,8 @@ export abstract class IgxGridBaseDirective implements GridType, }; if (this.isZonelessChangeDetection()) { this.cdr.detectChanges(); - callback(); - } else { - this.zone.onStable.pipe(first()).subscribe(callback); } + this.runAfterZoneStable(callback); this.disableTransitions = false; this.hideOverlays(); @@ -7798,7 +7780,15 @@ export abstract class IgxGridBaseDirective implements GridType, } protected isZonelessChangeDetection(): boolean { - return this.zone.constructor.name === 'NoopNgZone'; + return this.zone instanceof NoopNgZone; + } + + protected runAfterZoneStable(callback: () => void): void { + if (this.isZonelessChangeDetection()) { + callback(); + } else { + this.zone.onStable.pipe(first()).subscribe(callback); + } } protected hasMenuPinningActions(): boolean { @@ -7835,7 +7825,7 @@ export abstract class IgxGridBaseDirective implements GridType, emitChunkLoad(); } else { this.zone.run(() => { - this.zone.onStable.pipe(first()).subscribe(emitChunkLoad); + this.runAfterZoneStable(emitChunkLoad); }); } if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts index a58b84ff333..bfb4dd5b95a 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -21,7 +21,7 @@ * * Patterns NOT covered here (separate PRs) * ───────────────────────────────────────── - * - Pivot grid auto-size (fixed in pivot-grid.component.ts but no test added here) + * - Pivot grid auto-size (covered in pivot-grid.spec.ts) * - IgxForOfDirective/IgxGridForOfDirective zone.onStable fixes — covered by vkombov/fix-17280 * - IntersectionObserver zone.run fix in grid-base.directive.ts — covered by vkombov/fix-17280 * - Row editing overlay position (zone.onStable in RowEditPositionStrategy) diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts index 9ad7dbe2e60..79f542c0aac 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -1,7 +1,7 @@ import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, ElementRef, HostBinding, Input, OnInit, Output, QueryList, TemplateRef, ViewChild, ViewChildren, ContentChild, createComponent, CUSTOM_ELEMENTS_SCHEMA, booleanAttribute, OnChanges, SimpleChanges, inject } from '@angular/core'; import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; -import { first, take, takeUntil } from 'rxjs/operators'; +import { take, takeUntil } from 'rxjs/operators'; import { DEFAULT_PIVOT_KEYS, IDimensionsChange, IgxFilteringService, IgxGridNavigationService, IgxGridValidationService, IgxPivotDateDimension, IgxPivotGridValueTemplateContext, IPivotConfiguration, IPivotConfigurationChangedEventArgs, IPivotDimension, IPivotGridRecord, IPivotUISettings, IPivotValue, IValuesChange, PivotDimensionType, PivotRowLayoutType, PivotSummaryPosition, PivotUtil } from 'igniteui-angular/grids/core'; import { IgxGridSelectionService } from 'igniteui-angular/grids/core'; import { GridType, IGX_GRID_BASE, IGX_GRID_SERVICE_BASE, IgxColumnTemplateContext, PivotGridType, RowType } from 'igniteui-angular/grids/core'; @@ -2153,17 +2153,11 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - if (this.isZonelessChangeDetection()) { + this.runAfterZoneStable(() => { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); - } else { - this.zone.onStable.pipe(first()).subscribe(() => { - requestAnimationFrame(() => { - this.autoSizeDimensionsInView(); - }); - }); - } + }); } } diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts index 33a773cf136..0db6710b545 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts @@ -1,3 +1,4 @@ +import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -3525,3 +3526,63 @@ describe('IgxPivotGrid #pivotGrid', () => { }); }); }); + +describe('IgxPivotGrid - Zoneless Change Detection #pivotGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridTestBaseComponent + ], + providers: [ + IgxGridNavigationService, + provideZonelessChangeDetection() + ] + }).compileComponents(); + })); + + it('should auto-size row dimension when width is set to auto in zoneless mode', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxPivotGridTestBaseComponent); + fixture.componentInstance.pivotConfigHierarchy = { + columns: [{ + memberName: 'Country', + enabled: true + }], + rows: [{ + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + width: 'auto', + childLevel: { + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + } + }], + values: [{ + member: 'UnitPrice', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + enabled: true, + dataType: 'currency' + }], + filters: [] + }; + + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + const rowDimension = pivotGrid.pivotConfiguration.rows[0]; + + expect(rowDimension.autoWidth).toBeUndefined(); + + tick(16); + + expect(rowDimension.width).toBe('auto'); + expect(rowDimension.autoWidth).toBeGreaterThan(0); + expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(rowDimension.autoWidth); + })); +}); From 44b7971f4875315e7df5ec24136533b962a6fa10 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 29 Jun 2026 11:50:26 +0300 Subject: [PATCH 04/32] fix(for-of): recalculate virtual sizes in zoneless mode --- .../for-of/for_of.directive.spec.ts | 39 ++++++++++++++++++- .../src/directives/for-of/for_of.directive.ts | 36 +++++++++++++---- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index 0d8139e0c17..4a80336a2d5 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgClass, NgForOfContext } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, Directive, Injectable, IterableDiffers, NgZone, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, DebugElement, Pipe, PipeTransform, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Directive, Injectable, IterableDiffers, NgZone, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, DebugElement, Pipe, PipeTransform, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -990,6 +990,43 @@ describe('IgxForOf directive -', () => { }); }); + describe('zoneless change detection', () => { + it('should recalculate vertical sizes after scroll without relying on NgZone.onStable', () => { + TestBed.configureTestingModule({ + imports: [VerticalVirtualComponent], + providers: [provideZonelessChangeDetection()] + }); + const fix = TestBed.createComponent(VerticalVirtualComponent); + dg.generateData(300, 5, fix.componentInstance); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + const recalcSpy = spyOn(fix.componentInstance.parentVirtDir, 'recalcUpdateSizes').and.callThrough(); + + fix.componentInstance.scrollTop(100); + + expect(recalcSpy).toHaveBeenCalled(); + }); + + it('should recalculate horizontal sizes after scroll without relying on NgZone.onStable', () => { + TestBed.configureTestingModule({ + imports: [HorizontalVirtualComponent], + providers: [provideZonelessChangeDetection()] + }); + const fix = TestBed.createComponent(HorizontalVirtualComponent); + dg.generateData(300, 5, fix.componentInstance); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + const horizontalDir = fix.componentInstance.childVirtDirs.first; + const recalcSpy = spyOn(horizontalDir, 'recalcUpdateSizes').and.callThrough(); + + fix.componentInstance.scrollLeft(150); + + expect(recalcSpy).toHaveBeenCalled(); + }); + }); + describe('variable size component', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 0cee693395d..319ebad2476 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1,5 +1,5 @@ import { NgForOfContext } from '@angular/common'; -import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, booleanAttribute, DOCUMENT, inject, afterNextRender, runInInjectionContext, EnvironmentInjector, AfterViewInit } from '@angular/core'; +import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, booleanAttribute, DOCUMENT, inject, afterNextRender, runInInjectionContext, EnvironmentInjector, AfterViewInit, ɵNoopNgZone as NoopNgZone } from '@angular/core'; import { DisplayContainerComponent } from './display.container'; import { HVirtualHelperComponent } from './horizontal.virtual.helper.component'; @@ -970,9 +970,13 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes()); + this.dc.changeDetectorRef.detectChanges(); + } if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } @@ -1016,6 +1020,18 @@ export class IgxForOfDirective extends IgxForOfToken void): void { + if (this.isZonelessChangeDetection()) { + callback(); + } else { + this._zone.onStable.pipe(first()).subscribe(callback); + } + } + /** * @hidden */ @@ -1182,9 +1198,13 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes()); + this.dc.changeDetectorRef.detectChanges(); + } if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } @@ -1789,7 +1809,7 @@ export class IgxGridForOfDirective extends IgxForOfDirec afterNextRender({ write: () => { this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; - this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this, prevState)); + this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes(prevState)); } }); }); From 73c2072e6f157699dd5956ce8e0b9cab54eabff7 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 29 Jun 2026 13:46:12 +0300 Subject: [PATCH 05/32] chore(*): remove repeating code --- .../src/directives/for-of/for_of.directive.ts | 18 ++++-------------- .../grids/grid/src/grid-base.directive.ts | 6 +----- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 319ebad2476..5c6a48b107e 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -970,13 +970,8 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes()); - this.dc.changeDetectorRef.detectChanges(); - } + this.dc.changeDetectorRef.detectChanges(); + this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes()); if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } @@ -1198,13 +1193,8 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes()); - this.dc.changeDetectorRef.detectChanges(); - } + this.dc.changeDetectorRef.detectChanges(); + this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes()); if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index be061412146..5193023a25e 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -7822,12 +7822,8 @@ export abstract class IgxGridBaseDirective implements GridType, }; if (this.isZonelessChangeDetection()) { this.cdr.detectChanges(); - emitChunkLoad(); - } else { - this.zone.run(() => { - this.runAfterZoneStable(emitChunkLoad); - }); } + this.zone.run(() => this.runAfterZoneStable(emitChunkLoad)); if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); } From 2cb8ff3f19fd34e5f95789a0f042f867c0090670 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 29 Jun 2026 22:36:20 +0300 Subject: [PATCH 06/32] fix(grid): use afterNextRender for zoneless render scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NgZone.onStable never emits in zoneless apps, so grid paths that scheduled post-render work through it (auto-sizing, column virtualization, chunkLoad, virt-state restore) silently did nothing. Route the zoneless branch through afterNextRender — the render-aware boundary Angular recommends when onStable waited for a single render — and keep onStable for zone-based apps, so existing zoned timing is unchanged. The zoneless/zoned branch is centralized in one helper per directive (runAfterRender in grid/pivot, runAfterScrollViewUpdate in for-of). --- .../for-of/for_of.directive.spec.ts | 6 ++- .../src/directives/for-of/for_of.directive.ts | 7 +++- .../grids/grid/src/grid-base.directive.ts | 40 +++++++++---------- .../pivot-grid/src/pivot-grid.component.ts | 2 +- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index 4a80336a2d5..f67f372f1ac 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -991,7 +991,7 @@ describe('IgxForOf directive -', () => { }); describe('zoneless change detection', () => { - it('should recalculate vertical sizes after scroll without relying on NgZone.onStable', () => { + it('should recalculate vertical sizes after scroll without relying on NgZone.onStable', async () => { TestBed.configureTestingModule({ imports: [VerticalVirtualComponent], providers: [provideZonelessChangeDetection()] @@ -1004,11 +1004,12 @@ describe('IgxForOf directive -', () => { const recalcSpy = spyOn(fix.componentInstance.parentVirtDir, 'recalcUpdateSizes').and.callThrough(); fix.componentInstance.scrollTop(100); + await fix.whenStable(); expect(recalcSpy).toHaveBeenCalled(); }); - it('should recalculate horizontal sizes after scroll without relying on NgZone.onStable', () => { + it('should recalculate horizontal sizes after scroll without relying on NgZone.onStable', async () => { TestBed.configureTestingModule({ imports: [HorizontalVirtualComponent], providers: [provideZonelessChangeDetection()] @@ -1022,6 +1023,7 @@ describe('IgxForOf directive -', () => { const recalcSpy = spyOn(horizontalDir, 'recalcUpdateSizes').and.callThrough(); fix.componentInstance.scrollLeft(150); + await fix.whenStable(); expect(recalcSpy).toHaveBeenCalled(); }); diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 5c6a48b107e..53cf6038238 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1019,9 +1019,14 @@ export class IgxForOfDirective extends IgxForOfToken void): void { if (this.isZonelessChangeDetection()) { - callback(); + afterNextRender({ mixedReadWrite: callback }, { injector: this._injector }); } else { this._zone.onStable.pipe(first()).subscribe(callback); } diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 5193023a25e..f52e75b8e2b 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -31,6 +31,7 @@ import { DOCUMENT, inject, InjectionToken, + afterNextRender, ɵNoopNgZone as NoopNgZone } from '@angular/core'; import { @@ -4166,9 +4167,7 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasColumnsToAutosize) { this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); - this.runAfterZoneStable(() => { - this.autoSizeColumnsInView(); - }); + this.runAfterRender(() => this.autoSizeColumnsInView()); }); } // Window resize observer not needed because when you resize the window element the tbody container always resize so @@ -4681,7 +4680,7 @@ export abstract class IgxGridBaseDirective implements GridType, // reset auto-size and calculate it again. this._columns.forEach(x => x.autoSize = undefined); this.resetCaches(); - this.runAfterZoneStable(() => { + this.runAfterRender(() => { this.cdr.detectChanges(); this.autoSizeColumnsInView(); }); @@ -6386,7 +6385,7 @@ export abstract class IgxGridBaseDirective implements GridType, this._restoreVirtState(summaryRow); } }; - this.runAfterZoneStable(restoreState); + this.runAfterRender(restoreState); } } @@ -7092,9 +7091,7 @@ export abstract class IgxGridBaseDirective implements GridType, this.resetCaches(recalcFeatureWidth); if (this.hasColumnsToAutosize) { this.cdr.detectChanges(); - this.runAfterZoneStable(() => { - this._autoSizeColumnsNotify.next(); - }); + this.runAfterRender(() => this._autoSizeColumnsNotify.next()); } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7754,7 +7751,7 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.isZonelessChangeDetection()) { this.cdr.detectChanges(); } - this.runAfterZoneStable(callback); + this.runAfterRender(callback); this.disableTransitions = false; this.hideOverlays(); @@ -7783,9 +7780,14 @@ export abstract class IgxGridBaseDirective implements GridType, return this.zone instanceof NoopNgZone; } - protected runAfterZoneStable(callback: () => void): void { + /** + * Schedules `callback` to run once after the next render. + * Zone-based apps keep their existing `NgZone.onStable` scheduling untouched; + * zoneless apps use `afterNextRender`, since `onStable` never emits there. + */ + protected runAfterRender(callback: () => void): void { if (this.isZonelessChangeDetection()) { - callback(); + afterNextRender({ mixedReadWrite: callback }, { injector: this.injector }); } else { this.zone.onStable.pipe(first()).subscribe(callback); } @@ -7814,16 +7816,14 @@ export abstract class IgxGridBaseDirective implements GridType, this._horizontalForOfs.forEach(vfor => vfor.onHScroll(scrollLeft)); this.cdr.markForCheck(); - const emitChunkLoad = () => { - this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); - requestAnimationFrame(() => { - this.autoSizeColumnsInView(); + this.zone.run(() => { + this.runAfterRender(() => { + this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); + requestAnimationFrame(() => { + this.autoSizeColumnsInView(); + }); }); - }; - if (this.isZonelessChangeDetection()) { - this.cdr.detectChanges(); - } - this.zone.run(() => this.runAfterZoneStable(emitChunkLoad)); + }); if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); } diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts index 79f542c0aac..229924034de 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -2153,7 +2153,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - this.runAfterZoneStable(() => { + this.runAfterRender(() => { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); From 0b65db108a49373d40d1c070383174ad88f1e964 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Thu, 2 Jul 2026 10:45:33 +0300 Subject: [PATCH 07/32] fix(grid): improve virtualized keyboard selection handling --- .../grids/core/src/grid-navigation.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts index d3edc7a21e3..dd506e521db 100644 --- a/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts +++ b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts @@ -33,6 +33,8 @@ export interface IActiveNode { layout?: IMultiRowLayoutNode; } +const VERTICAL_VIRTUALIZATION_NAV_KEYS = new Set(['arrowup', 'up', 'arrowdown', 'down', 'home', 'end']); + /** @hidden */ @Injectable() export class IgxGridNavigationService { @@ -106,8 +108,7 @@ export class IgxGridNavigationService { } const position = this.getNextPosition(this.activeNode.row, this.activeNode.column, key, shift, ctrl, event); const shouldNotifyVirtualizedKeyboardSelection = - ctrl && (key === 'arrowup' || key === 'up' || key === 'arrowdown' || key === 'down') && - this.shouldPerformVerticalScroll(position.rowIndex, position.colIndex); + this.shouldNotifyVirtualizedKeyboardSelection(key, ctrl, position.rowIndex, position.colIndex); if (NAVIGATION_KEYS.has(key)) { event.preventDefault(); this.navigateInBody(position.rowIndex, position.colIndex, (obj) => { @@ -231,6 +232,14 @@ export class IgxGridNavigationService { || containerHeight && endTopOffset - containerHeight > 5; } + protected shouldNotifyVirtualizedKeyboardSelection(key: string, ctrl: boolean, rowIndex: number, visibleColIndex: number): boolean { + const shouldCheckVerticalScroll = ctrl && VERTICAL_VIRTUALIZATION_NAV_KEYS.has(key); + const shouldCheckHorizontalScroll = HORIZONTAL_NAV_KEYS.has(key); + + return (shouldCheckVerticalScroll && this.shouldPerformVerticalScroll(rowIndex, visibleColIndex)) || + (shouldCheckHorizontalScroll && this.shouldPerformHorizontalScroll(visibleColIndex, rowIndex)); + } + public performVerticalScrollToCell(rowIndex: number, visibleColIndex = -1, cb?: () => void) { if (!this.shouldPerformVerticalScroll(rowIndex, visibleColIndex)) { if (cb) { From 230e886cbca04557b5b9c1b3c0c29886f2ebe72e Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Sun, 5 Jul 2026 23:08:19 +0300 Subject: [PATCH 08/32] fix(grids): migrate virt & nav to zoneless-compatible scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deferred grid work (scroll chunkLoad emit, column autosizing, filter-row rendering, post-scroll cell activation) relied on NgZone.onStable, which never emits under provideZonelessChangeDetection. Replace it with a single runAfterRenderOnce helper built on afterNextRender, and remove the private ɵNoopNgZone zoneless detection. - core: add runAfterRenderOnce(injector, cb, phase) central scheduler - grids: route for_of, grid-base, pivot-grid deferred work through it - filtering: signal-backed isFilterRowVisible; ResizeObserver-driven chip remeasure; explicit notify on column resize - navigation: notify on any scrolling nav key; subscribe before scroll in hierarchical navigation - tests: add zoneless duplicates and centralize scroll/nav settle helpers; update zoned originals to the new render-boundary timing --- .../igniteui-angular/core/src/core/utils.ts | 20 +- .../src/directives/for-of/for_of.directive.ts | 62 +- .../base/grid-filtering-cell.component.ts | 16 +- .../src/filtering/grid-filtering.service.ts | 15 +- .../grids/core/src/grid-navigation.service.ts | 10 +- .../core/src/resizing/resizing.service.ts | 21 +- .../grids/grid/src/column-group.spec.ts | 41 +- .../grids/grid/src/column.spec.ts | 64 ++- .../grids/grid/src/grid-base.directive.ts | 55 +- .../grid/src/grid-cell-selection.spec.ts | 47 +- .../grids/grid/src/grid-filtering-ui.spec.ts | 201 ++++++- .../grid/src/grid-keyBoardNav-headers.spec.ts | 94 +++- .../grids/grid/src/grid-keyBoardNav.spec.ts | 134 ++++- .../grid/src/grid-mrl-keyboard-nav.spec.ts | 251 +++++++-- .../grids/grid/src/grid.component.spec.ts | 44 +- .../grids/grid/src/grid.groupby.spec.ts | 70 ++- .../grids/grid/src/grid.master-detail.spec.ts | 165 +++++- .../grids/grid/src/grid.search.spec.ts | 54 +- .../grids/grid/src/grid.zoneless.spec.ts | 13 +- .../hierarchical-grid-navigation.service.ts | 4 +- .../src/hierarchical-grid.navigation.spec.ts | 528 ++++++++++++++++-- .../hierarchical-grid.virtualization.spec.ts | 139 ++++- .../src/pivot-grid-keyboard-nav.spec.ts | 9 +- .../pivot-grid/src/pivot-grid.component.ts | 4 +- .../grids/pivot-grid/src/pivot-grid.spec.ts | 61 -- .../src/tree-grid-keyBoardNav.spec.ts | 478 +++++++++++++++- .../tree-grid/src/tree-grid-summaries.spec.ts | 59 +- .../tree-grid/src/tree-grid.component.spec.ts | 48 +- .../test-utils/helper-utils.spec.ts | 153 ++++- 29 files changed, 2531 insertions(+), 329 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index f7f6a7b66e2..f37864bbcd2 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -1,5 +1,5 @@ import { isPlatformBrowser } from '@angular/common'; -import { Injectable, InjectionToken, PLATFORM_ID, inject } from '@angular/core'; +import { Injectable, InjectionToken, PLATFORM_ID, inject, afterNextRender, type AfterRenderRef, type Injector } from '@angular/core'; import { mergeWith } from 'lodash-es'; import { NEVER, Observable } from 'rxjs'; import { isDevMode } from '@angular/core'; @@ -8,6 +8,24 @@ import type { IgxTheme } from '../services/theme/theme.token'; /** @hidden @internal */ export const ELEMENTS_TOKEN = /*@__PURE__*/new InjectionToken('elements environment'); +/** @hidden @internal */ +export type RenderPhase = 'earlyRead' | 'write' | 'mixedReadWrite' | 'read'; + +/** + * Schedules `callback` to run once after Angular finishes the next render pass. + * + * Central scheduling point for all work that previously waited on `NgZone.onStable`, + * which never emits in zoneless applications. Every deferred render callback in the + * library goes through here, so if the scheduling needs to change (different phase, + * timing or API), change it in this single place. + * + * @hidden @internal + */ +export function runAfterRenderOnce(injector: Injector, callback: () => void, phase: RenderPhase = 'mixedReadWrite'): AfterRenderRef { + const spec: Partial void>> = {}; + spec[phase] = callback; + return afterNextRender(spec as { mixedReadWrite: () => void }, { injector }); +} /** * Returns true if the element's direction is left-to-right diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 53cf6038238..310983916bf 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1,5 +1,5 @@ import { NgForOfContext } from '@angular/common'; -import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, booleanAttribute, DOCUMENT, inject, afterNextRender, runInInjectionContext, EnvironmentInjector, AfterViewInit, ɵNoopNgZone as NoopNgZone } from '@angular/core'; +import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, booleanAttribute, DOCUMENT, inject, EnvironmentInjector, AfterViewInit } from '@angular/core'; import { DisplayContainerComponent } from './display.container'; import { HVirtualHelperComponent } from './horizontal.virtual.helper.component'; @@ -7,8 +7,8 @@ import { VirtualHelperComponent } from './virtual.helper.component'; import { IgxForOfSyncService, IgxForOfScrollSyncService } from './for_of.sync.service'; import { Subject } from 'rxjs'; -import { takeUntil, filter, throttleTime, first } from 'rxjs/operators'; -import { getResizeObserver } from 'igniteui-angular/core'; +import { takeUntil, filter, throttleTime } from 'rxjs/operators'; +import { getResizeObserver, runAfterRenderOnce } from 'igniteui-angular/core'; import { IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; import { VirtualHelperBaseDirective } from './base.helper.component'; @@ -659,13 +659,9 @@ export class IgxForOfDirective extends IgxForOfToken { - afterNextRender({ - write: () => { - this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; - } - }); - }); + runAfterRenderOnce(this._injector, () => { + this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; + }, 'write'); } const maxRealScrollTop = this.scrollComponent.nativeElement.scrollHeight - containerSize; @@ -912,7 +908,7 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken { - afterNextRender({ - write: () => { - this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; - } - }); - }); + runAfterRenderOnce(this._injector, () => { + this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; + }, 'write'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(), 'read'); this.dc.changeDetectorRef.detectChanges(); - this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes()); if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } @@ -1015,23 +1007,6 @@ export class IgxForOfDirective extends IgxForOfToken void): void { - if (this.isZonelessChangeDetection()) { - afterNextRender({ mixedReadWrite: callback }, { injector: this._injector }); - } else { - this._zone.onStable.pipe(first()).subscribe(callback); - } - } - /** * @hidden */ @@ -1198,8 +1173,9 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes(), 'read'); + this.dc.changeDetectorRef.detectChanges(); - this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes()); if (prevStartIndex !== this.state.startIndex) { this.chunkLoad.emit(this.state); } @@ -1800,14 +1776,10 @@ export class IgxGridForOfDirective extends IgxForOfDirec } const prevState = Object.assign({}, this.state); const scrollOffset = this.fixedUpdateAllElements(this._virtScrollPosition); - runInInjectionContext(this._injector, () => { - afterNextRender({ - write: () => { - this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; - this.runAfterScrollViewUpdate(() => this.recalcUpdateSizes(prevState)); - } - }); - }); + runAfterRenderOnce(this._injector, () => { + this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; + }, 'write'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(prevState), 'read'); this.cdr.markForCheck(); } diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts index 07c5311afb5..135aa5c05cd 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts @@ -1,4 +1,5 @@ -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, HostBinding, Input, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, DoCheck, ElementRef, HostBinding, Input, NgZone, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IgxFilteringService } from '../grid-filtering.service'; import { ExpressionUI } from '../excel-style/common'; import { NgClass, NgTemplateOutlet } from '@angular/common'; @@ -6,7 +7,7 @@ import { IBaseChipEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'ign import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxPrefixDirective } from 'igniteui-angular/input-group'; import { IgxBadgeComponent } from 'igniteui-angular/badge'; -import { ColumnType, IFilteringExpression, ɵSize } from 'igniteui-angular/core'; +import { ColumnType, IFilteringExpression, resizeObservable, ɵSize } from 'igniteui-angular/core'; /** * @hidden @@ -28,6 +29,9 @@ import { ColumnType, IFilteringExpression, ɵSize } from 'igniteui-angular/core' export class IgxGridFilteringCellComponent implements AfterViewInit, OnInit, DoCheck { public cdr = inject(ChangeDetectorRef); public filteringService = inject(IgxFilteringService); + private zone = inject(NgZone); + private elementRef = inject(ElementRef); + private destroyRef = inject(DestroyRef); @Input() public column: ColumnType; @@ -94,6 +98,14 @@ export class IgxGridFilteringCellComponent implements AfterViewInit, OnInit, DoC public ngAfterViewInit(): void { this.updateFilterCellArea(); + // The visible chips calculation measures the rendered cell, so it must rerun when + // the cell's size actually changes (column/grid resize). ZoneJS apps got this for + // free from zone-triggered ticks; observing the element covers zoneless apps too. + this.zone.runOutsideAngular(() => { + resizeObservable(this.elementRef.nativeElement) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.zone.run(() => this.cdr.markForCheck())); + }); } public ngDoCheck() { diff --git a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts index a196db3474a..115d5b704a9 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Injectable, OnDestroy, inject, signal } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil, first } from 'rxjs/operators'; import { IColumnResizeEventArgs, IFilteringEventArgs } from '../common/events'; @@ -21,7 +21,18 @@ export class IgxFilteringService implements OnDestroy { private iconService = inject(IgxIconService); protected _overlayService = inject(IgxOverlayService); - public isFilterRowVisible = false; + // Signal-backed so that OnPush views reading it in templates (e.g. the header row's + // filtering row @if) re-render when it changes, without relying on ZoneJS ticks. + private _isFilterRowVisible = signal(false); + + public get isFilterRowVisible(): boolean { + return this._isFilterRowVisible(); + } + + public set isFilterRowVisible(value: boolean) { + this._isFilterRowVisible.set(value); + } + public filteredColumn: ColumnType = null; public selectedExpression: IFilteringExpression = null; public columnToMoreIconHidden = new Map(); diff --git a/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts index dd506e521db..b4195b8c32d 100644 --- a/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts +++ b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts @@ -108,7 +108,7 @@ export class IgxGridNavigationService { } const position = this.getNextPosition(this.activeNode.row, this.activeNode.column, key, shift, ctrl, event); const shouldNotifyVirtualizedKeyboardSelection = - this.shouldNotifyVirtualizedKeyboardSelection(key, ctrl, position.rowIndex, position.colIndex); + this.shouldNotifyVirtualizedKeyboardSelection(key, position.rowIndex, position.colIndex); if (NAVIGATION_KEYS.has(key)) { event.preventDefault(); this.navigateInBody(position.rowIndex, position.colIndex, (obj) => { @@ -232,14 +232,16 @@ export class IgxGridNavigationService { || containerHeight && endTopOffset - containerHeight > 5; } - protected shouldNotifyVirtualizedKeyboardSelection(key: string, ctrl: boolean, rowIndex: number, visibleColIndex: number): boolean { - const shouldCheckVerticalScroll = ctrl && VERTICAL_VIRTUALIZATION_NAV_KEYS.has(key); + protected shouldNotifyVirtualizedKeyboardSelection(key: string, rowIndex: number, visibleColIndex: number): boolean { + // Any navigation key that ends up scrolling activates the target cell from the + // virtualization scroll callback, which runs outside Angular's knowledge, so the + // grid must be notified explicitly regardless of the ctrl modifier. + const shouldCheckVerticalScroll = VERTICAL_VIRTUALIZATION_NAV_KEYS.has(key); const shouldCheckHorizontalScroll = HORIZONTAL_NAV_KEYS.has(key); return (shouldCheckVerticalScroll && this.shouldPerformVerticalScroll(rowIndex, visibleColIndex)) || (shouldCheckHorizontalScroll && this.shouldPerformHorizontalScroll(visibleColIndex, rowIndex)); } - public performVerticalScrollToCell(rowIndex: number, visibleColIndex = -1, cb?: () => void) { if (!this.shouldPerformVerticalScroll(rowIndex, visibleColIndex)) { if (cb) { diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts index 4bb2cc39758..975dc67c425 100644 --- a/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts +++ b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts @@ -1,5 +1,5 @@ -import { inject, Injectable, NgZone } from '@angular/core'; -import { ColumnType } from 'igniteui-angular/core'; +import { inject, Injectable, Injector, NgZone } from '@angular/core'; +import { ColumnType, runAfterRenderOnce } from 'igniteui-angular/core'; /** * @hidden @@ -8,6 +8,7 @@ import { ColumnType } from 'igniteui-angular/core'; @Injectable() export class IgxColumnResizingService { private zone = inject(NgZone); + private injector = inject(Injector); /** @@ -38,6 +39,18 @@ export class IgxColumnResizingService { return parseFloat(window.getComputedStyle(this.column.headerCell.nativeElement).width); } + /** + * Notifies the grid that a column width changed from a pointer interaction, which + * Angular does not observe on its own. The empty zone entry preserves the original + * tick timing for ZoneJS apps; the post-render notification covers zoneless apps + * (where entering the zone is a no-op) and lets DOM-measuring checks — e.g. the + * filter cells' visible chips calculation — run against the resized layout. + */ + private notifyResized() { + this.zone.run(() => { }); + runAfterRenderOnce(this.injector, () => this.column.grid.notifyChanges()); + } + /** * @hidden */ @@ -89,7 +102,7 @@ export class IgxColumnResizingService { const currentColWidth = this.getColumnHeaderRenderedWidth(); this.column.width = this.column.getAutoSize(); - this.zone.run(() => { }); + this.notifyResized(); this.column.grid.columnResized.emit({ column: this.column, @@ -120,7 +133,7 @@ export class IgxColumnResizingService { } - this.zone.run(() => { }); + this.notifyResized(); if (currentColWidth !== parseFloat(this.column.width)) { this.column.grid.columnResized.emit({ diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts index 232dc7f0d15..607f36b5e8e 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -1,6 +1,6 @@ import { TestBed, ComponentFixture, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; import { IgxGridComponent } from './grid.component'; -import { DebugElement, QueryList } from '@angular/core'; +import { DebugElement, QueryList, provideZonelessChangeDetection } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxColumnComponent } from 'igniteui-angular/grids/core'; import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; @@ -608,13 +608,15 @@ describe('IgxGrid - multi-column headers #grid', () => { grid = fixture.componentInstance.grid; })); - it('Width should be correct. Column group with three columns. No width.', () => { + it('Width should be correct. Column group with three columns. No width.', async () => { + await wait(16); + fixture.detectChanges(); const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; const availableWidth = (parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh).toString(); const locationColGroup = getColGroup(grid, 'Location'); - const colWidth = Math.floor(parseInt(availableWidth, 10) / 3); + const colWidth = parseInt(availableWidth, 10) / 3; const colWidthPx = colWidth + 'px'; - expect(locationColGroup.width).toBe((Math.round(colWidth) * 3) + 'px'); + expect(locationColGroup.width).toBe((colWidth * 3) + 'px'); const countryColumn = grid.getColumnByName('Country'); expect(countryColumn.width).toBe(colWidthPx); const regionColumn = grid.getColumnByName('Region'); @@ -1761,6 +1763,36 @@ describe('IgxGrid - multi-column headers #grid', () => { expect(firstGroupedRow.records.length).toEqual(6); }); }); + describe('Columns widths tests in zoneless change detection (1 group 3 columns) ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + })); + + it('Width should be correct. Column group with three columns. No width.', async () => { + fixture = TestBed.createComponent(OneGroupThreeColsGridComponent); + fixture.detectChanges(); + await fixture.whenStable(); + await wait(16); + await fixture.whenStable(); + componentInstance = fixture.componentInstance; + grid = fixture.componentInstance.grid; + + const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; + const availableWidth = (parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh).toString(); + const locationColGroup = getColGroup(grid, 'Location'); + const colWidth = parseInt(availableWidth, 10) / 3; + const colWidthPx = colWidth + 'px'; + expect(locationColGroup.width).toBe((colWidth * 3) + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(colWidthPx); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(colWidthPx); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(colWidthPx); + }); + }); }); const getColGroup = (grid: IgxGridComponent, headerName: string): IgxColumnGroupComponent => { @@ -1906,4 +1938,3 @@ class NestedColGroupsTests { 'slaveColGroup', masterColGroupChildrenCount); } } - diff --git a/projects/igniteui-angular/grids/grid/src/column.spec.ts b/projects/igniteui-angular/grids/grid/src/column.spec.ts index 5d875b8fb09..c467659fc3e 100644 --- a/projects/igniteui-angular/grids/grid/src/column.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column.spec.ts @@ -1,4 +1,4 @@ -import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { getLocaleCurrencySymbol, registerLocaleData } from '@angular/common'; @@ -1420,6 +1420,68 @@ describe('IgxGrid - Column properties #grid', () => { }); + describe('Date, DateTime and Time column tests in zoneless change detection', () => { + let grid: IgxGridComponent; + let fix: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + fix = TestBed.createComponent(IgxGridDateTimeColumnComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + }); + + it('Date/Time/DateTime: Use default locale format as inputFormat when editorOptions/pipeArgs formats are null/empty ', async () => { + const producedDateColumn = grid.getColumnByName('ProducedDate'); + const orderDateColumn = grid.getColumnByName('OrderDate'); + const receiveTimeColumn = grid.getColumnByName('ReceiveTime'); + + + producedDateColumn.editorOptions = null; + orderDateColumn.editorOptions.dateTimeFormat = ''; + receiveTimeColumn.pipeArgs = { + format: undefined + }; + await fix.whenStable(); + + producedDateColumn._cells[0].setEditMode(true) + await fix.whenStable(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + await wait(16); + await fix.whenStable(); + + expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); + + orderDateColumn._cells[0].setEditMode(true) + await fix.whenStable(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + await wait(16); + await fix.whenStable(); + + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); + + receiveTimeColumn._cells[0].setEditMode(true) + await fix.whenStable(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + await wait(16); + await fix.whenStable(); + + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('08:37 AM'); + }); + }); + describe('Data type image column tests', () => { let fix: ComponentFixture; let grid: IgxGridComponent; diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 5509d166992..2723c519ee3 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -30,9 +30,7 @@ import { ViewContainerRef, DOCUMENT, inject, - InjectionToken, - afterNextRender, - ɵNoopNgZone as NoopNgZone + InjectionToken } from '@angular/core'; import { areEqualArrays, @@ -93,7 +91,8 @@ import { IGridResourceStrings, IgxOverlayOutletDirective, DEFAULT_LOCALE, - onResourceChangeHandle + onResourceChangeHandle, + runAfterRenderOnce } from 'igniteui-angular/core'; import { IgcTrialWatermark } from 'igniteui-trial-watermark'; import { Subject, pipe, fromEvent, animationFrameScheduler, merge, BehaviorSubject, timer } from 'rxjs'; @@ -4168,7 +4167,7 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasColumnsToAutosize) { this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); - this.runAfterRender(() => this.autoSizeColumnsInView()); + runAfterRenderOnce(this.injector, () => this.autoSizeColumnsInView()); }); } // Window resize observer not needed because when you resize the window element the tbody container always resize so @@ -4681,7 +4680,7 @@ export abstract class IgxGridBaseDirective implements GridType, // reset auto-size and calculate it again. this._columns.forEach(x => x.autoSize = undefined); this.resetCaches(); - this.runAfterRender(() => { + runAfterRenderOnce(this.injector, () => { this.cdr.detectChanges(); this.autoSizeColumnsInView(); }); @@ -6377,7 +6376,7 @@ export abstract class IgxGridBaseDirective implements GridType, const tmplId = args.context.templateID.type; const index = args.context.index; args.view.detectChanges(); - const restoreState = () => { + runAfterRenderOnce(this.injector, () => { const row = tmplId === 'dataRow' ? this.gridAPI.get_row_by_index(index) : null; const summaryRow = tmplId === 'summaryRow' ? this.summariesRowList.find((sr) => sr.dataRowIndex === index) : null; if (row && row instanceof IgxRowDirective) { @@ -6385,8 +6384,7 @@ export abstract class IgxGridBaseDirective implements GridType, } else if (summaryRow) { this._restoreVirtState(summaryRow); } - }; - this.runAfterRender(restoreState); + }); } } @@ -7077,22 +7075,16 @@ export abstract class IgxGridBaseDirective implements GridType, this.cdr.detectChanges(); } - if (this.zone.isStable) { + runAfterRenderOnce(this.injector, () => { this.zone.run(() => { this._applyWidthHostBinding(); this.cdr.detectChanges(); }); - } else { - this.zone.onStable.pipe(first()).subscribe(() => { - this.zone.run(() => { - this._applyWidthHostBinding(); - }); - }); - } + }); this.resetCaches(recalcFeatureWidth); if (this.hasColumnsToAutosize) { this.cdr.detectChanges(); - this.runAfterRender(() => this._autoSizeColumnsNotify.next()); + runAfterRenderOnce(this.injector, () => this._autoSizeColumnsNotify.next()); } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7742,17 +7734,13 @@ export abstract class IgxGridBaseDirective implements GridType, protected verticalScrollHandler(event) { this.verticalScrollContainer.onScroll(event); this.disableTransitions = true; - const callback = () => { this.verticalScrollContainer.chunkLoad.emit(this.verticalScrollContainer.state); if (this.rowEditable) { this.changeRowEditingOverlayStateOnScroll(this.crudService.rowInEditMode); } }; - if (this.isZonelessChangeDetection()) { - this.cdr.detectChanges(); - } - this.runAfterRender(callback); + runAfterRenderOnce(this.injector, callback, 'read'); this.disableTransitions = false; this.hideOverlays(); @@ -7777,23 +7765,6 @@ export abstract class IgxGridBaseDirective implements GridType, this.gridScroll.emit(args); } - protected isZonelessChangeDetection(): boolean { - return this.zone instanceof NoopNgZone; - } - - /** - * Schedules `callback` to run once after the next render. - * Zone-based apps keep their existing `NgZone.onStable` scheduling untouched; - * zoneless apps use `afterNextRender`, since `onStable` never emits there. - */ - protected runAfterRender(callback: () => void): void { - if (this.isZonelessChangeDetection()) { - afterNextRender({ mixedReadWrite: callback }, { injector: this.injector }); - } else { - this.zone.onStable.pipe(first()).subscribe(callback); - } - } - protected hasMenuPinningActions(): boolean { const strip = this.actionStrip; const actionButtons = strip?.actionButtons; @@ -7818,12 +7789,12 @@ export abstract class IgxGridBaseDirective implements GridType, this.cdr.markForCheck(); this.zone.run(() => { - this.runAfterRender(() => { + runAfterRenderOnce(this.injector, () => { this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); requestAnimationFrame(() => { this.autoSizeColumnsInView(); }); - }); + }, 'read'); }); if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts index cb622a8250f..19c0354fc3c 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -10,11 +10,11 @@ import { IgxGridRowEditingWithoutEditableColumnsComponent } from '../../../test-utils/grid-samples.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; import { GridSelectionMode } from 'igniteui-angular/grids/core'; import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { DropPosition } from 'igniteui-angular/grids/core'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; @@ -1456,8 +1456,9 @@ describe('IgxGrid - Cell selection #grid', () => { expect(grid.selectedCells.length).toBe(1); UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); - await wait(200); - fix.detectChanges(); + // Ctrl+End scrolls the virtualized grid before the range is finalized; wait + // for the selection to settle instead of racing it with a fixed delay. + await waitForGridSettle(fix, () => selectionChangeSpy.calls.count() >= 1); expect(selectionChangeSpy).toHaveBeenCalledTimes(1); GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); @@ -1737,6 +1738,44 @@ describe('IgxGrid - Cell selection #grid', () => { })); }); + describe('Keyboard navigation in zoneless change detection', () => { + let fix: ComponentFixture; + let grid; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Should handle Shift + Ctrl + End keys combination', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await wait(); + await fix.whenStable(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); + await wait(200); + await fix.whenStable(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 2, 5); + })); + }); + describe('Features integration', () => { let fix; let grid; diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts index ead0ee46145..001797c31a8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts @@ -1,4 +1,4 @@ -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -2584,8 +2584,8 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { tick(200); const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; expect(resizer).toBeDefined(); - UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); - UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mousemove', resizer, 150, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 150, 5); fix.detectChanges(); colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); @@ -3213,6 +3213,201 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { closeChipFromFilteringUIRow(fix, grid, 'ReleaseTime', 4); })); }); + + describe('Filtering row UI actions in zoneless change detection', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: INPUT_DEBOUNCE_TIME, useValue: 0 } + ] + }); + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('should hide chip arrows when the grid is narrow and column is not filtered', async () => { + grid.width = '400px'; + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // Click string filter chip to show filter row. + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // Verify arrows and chip area are not visible because there is no active filtering for the column. + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const chipArea = filteringRow.query(By.css('igx-chip-area')); + expect(GridFunctions.getFilterRowLeftArrowButton(fix)).toBeNull(); + expect(GridFunctions.getFilterRowRightArrowButton(fix)).toBeNull(); + expect(chipArea).toBeNull('chipArea is present'); + }); + + it('Should navigate from left arrow button to first condition chip Tab.', (async () => { + grid.width = '700px'; + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await fix.whenStable(); + + // Add first chip. + GridFunctions.typeValueInFilterRowInput('a', fix); + await wait(16); + await fix.whenStable(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + await fix.whenStable(); + // Add second chip. + GridFunctions.typeValueInFilterRowInput('e', fix); + await wait(16); + await fix.whenStable(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + await fix.whenStable(); + // Add third chip. + GridFunctions.typeValueInFilterRowInput('i', fix); + await wait(16); + await fix.whenStable(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + await fix.whenStable(); + + // Verify first chip is not in view. + verifyChipVisibility(fix, 0, false); + + const leftArrowButton = GridFunctions.getFilterRowLeftArrowButton(fix).nativeElement; + leftArrowButton.focus(); + await wait(16); + await fix.whenStable(); + leftArrowButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + await wait(100); + await fix.whenStable(); + + // Verify first chip is in view. + verifyChipVisibility(fix, 0, true); + })); + }); + + describe('Filtering row integration scenarios in zoneless change detection', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: INPUT_DEBOUNCE_TIME, useValue: 0 } + ] + }); + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('should display the Row Selector header checkbox above the filter row.', async () => { + grid.rowSelection = GridSelectionMode.multiple; + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + const chkBoxElem = GridSelectionFunctions.getRowCheckboxInput(GridSelectionFunctions.getHeaderRow(fix)); + expect(frElem.offsetTop).toBeGreaterThanOrEqual(chkBoxElem.offsetTop + chkBoxElem.clientHeight); + }); + + it('should display the header expand/collapse icon for groupby above the filter row.', async () => { + grid.getColumnByName('ProductName').groupable = true; + grid.groupBy({ + fieldName: 'ProductName', + dir: SortingDirection.Asc, + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + const expandBtn = fix.debugElement.query(By.css('.igx-grid__group-expand-btn')); + const expandBtnElem = expandBtn.nativeElement; + expect(frElem.offsetTop).toBeGreaterThanOrEqual(expandBtnElem.offsetTop + expandBtnElem.clientHeight); + }); + + it('Should display view more indicator when column is resized so not all filters are visible.', async () => { + grid.columnList.get(1).width = '250px'; + await fix.whenStable(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'o', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + let colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + let colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); + let colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + expect(colChips.length).toEqual(2); + expect(colOperands.length).toEqual(1); + expect(colIndicator.length).toEqual(0); + + // Enable resizing + fix.componentInstance.resizable = true; + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // Make 'ProductName' column smaller + const headers: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerResArea = headers[1].children[2].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + await wait(200); + await fix.whenStable(); + const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); + colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + // The exact number of chips that fit depends on rendered chip metrics, which vary + // slightly across environments; the contract is that not all filters fit anymore + // and the "view more" indicator badge reports the hidden count. + expect(colChips.length).toBeLessThan(2); + expect(colOperands.length).toEqual(Math.max(colChips.length - 1, 0)); + if (colChips.length > 0) { + expect(GridFunctions.getChipText(colChips[0])).toEqual('a'); + } + expect(colIndicator.length).toEqual(1); + + const indicatorBadge = colIndicator[0].query(By.directive(IgxBadgeComponent)); + expect(indicatorBadge).toBeTruthy(); + expect(indicatorBadge.nativeElement.innerText.trim()).toEqual(`${2 - colChips.length}`); + }); + }); }); describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts index 6514e85712d..d1874d10047 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts @@ -1,9 +1,10 @@ import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideZonelessChangeDetection } from '@angular/core'; import { IgxGridComponent } from './grid.component'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; import { SelectionWithScrollsComponent, MRLTestComponent, @@ -63,12 +64,13 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { it('should focus first header when the grid is scrolled', async () => { grid.navigateTo(7, 5); - await wait(250); - fix.detectChanges(); + // navigateTo scrolls both axes; drive the deferred scroll processing so the + // columns land in view before focusing the header. + await dispatchGridScrollEvents(fix, grid, { waitMs: 250 }); gridHeader.nativeElement.focus(); //('focus', {}); - await wait(250); - fix.detectChanges(); + await waitForGridSettle(fix, () => + grid.navigation.activeNode?.row === -1 && grid.navigation.activeNode?.column === 3); const header = GridFunctions.getColumnHeader('ID', fix); expect(header).not.toBeDefined(); @@ -477,33 +479,35 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); }); - it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', fakeAsync(() => { + // fakeAsync's tick does not flush the afterNextRender pass that applies the + // restored header focus/active state; drive it with real timers + whenStable. + it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', async () => { // Enable Advanced Filtering grid.allowAdvancedFiltering = true; - fix.detectChanges(); + await fix.whenStable(); let header = GridFunctions.getColumnHeader('Name', fix); UIInteractions.simulateClickAndSelectEvent(header); - fix.detectChanges(); + await fix.whenStable(); // Verify first header is focused GridFunctions.verifyHeaderIsFocused(header.parent); UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); - fix.detectChanges(); + await fix.whenStable(); // Verify AF dialog is opened. expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); const afDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); UIInteractions.triggerKeyDownEvtUponElem('Escape', afDialog); - tick(100); - fix.detectChanges(); + await wait(100); + await fix.whenStable(); // Verify AF dialog is closed. header = GridFunctions.getColumnHeader('Name', fix); expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); GridFunctions.verifyHeaderIsFocused(header.parent); - })); + }); it('Column selection: Should be able to select columns when columnSelection is multi', () => { @@ -760,6 +764,72 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { }); }); + describe('Headers Navigation in zoneless change detection', () => { + let fix; + let grid: IgxGridComponent; + let gridHeader: IgxGridHeaderRowComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + SelectionWithScrollsComponent, NoopAnimationsModule + ], + providers: [ + provideZonelessChangeDetection(), + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridHeader = GridFunctions.getGridHeader(grid); + }); + + it('should focus first header when the grid is scrolled', async () => { + grid.navigateTo(7, 5); + await wait(250); + await fix.whenStable(); + + gridHeader.nativeElement.focus(); + await wait(250); + await fix.whenStable(); + + const header = GridFunctions.getColumnHeader('ID', fix); + expect(header).not.toBeDefined(); + expect(grid.navigation.activeNode.column).toEqual(3); + expect(grid.navigation.activeNode.row).toEqual(-1); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(200); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(100); + }); + + it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', async () => { + grid.allowAdvancedFiltering = true; + await fix.whenStable(); + let header = GridFunctions.getColumnHeader('Name', fix); + UIInteractions.simulateClickAndSelectEvent(header); + await fix.whenStable(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); + await fix.whenStable(); + + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); + + const afDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); + UIInteractions.triggerKeyDownEvtUponElem('Escape', afDialog); + await wait(100); + await fix.whenStable(); + + header = GridFunctions.getColumnHeader('Name', fix); + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + GridFunctions.verifyHeaderIsFocused(header.parent); + }); + }); + describe('MRL Headers Navigation', () => { let fix; let grid: IgxGridComponent; diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts index 91b3e06a24e..0ba8931c20c 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts @@ -11,7 +11,7 @@ import { } from '../../../test-utils/grid-samples.spec'; import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement, QueryList } from '@angular/core'; +import { DebugElement, QueryList, provideZonelessChangeDetection } from '@angular/core'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { CellType } from 'igniteui-angular/grids/core'; import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; @@ -498,6 +498,13 @@ describe('IgxGrid - Keyboard navigation #grid', () => { fix.componentInstance.data = fix.componentInstance.generateData(500); fix.detectChanges(); + grid = fix.componentInstance.grid; + + // Zone.js tests do not auto-flush change detection on NgZone stability inside + // TestBed, and the virtualization scroll pipeline (onScroll -> markForCheck -> + // runAfterRenderOnce) only applies its pending view updates once a CD tick runs. + // An explicit detectChanges() after each real-time wait is required here (unlike + // the zoneless variant below, whose scheduler flushes this automatically). grid.verticalScrollContainer.addScrollTop(5000); await wait(100); fix.detectChanges(); @@ -520,9 +527,20 @@ describe('IgxGrid - Keyboard navigation #grid', () => { await wait(); fix.detectChanges(); + // Ctrl+End targets column 49, which (unlike column 0 for Home) is not in the + // horizontal viewport, so grid.navigateTo() chains a vertical scroll AND a + // horizontal scroll: performVerticalScrollToCell() waits for the vertical + // chunkLoad, then its callback triggers performHorizontalScrollToCell(), which + // waits for a SEPARATE horizontal chunkLoad before activating the target cell. + // The horizontal scrollTo() call only happens once the first detectChanges() + // flushes the vertical chunk load above, so a second real wait + detectChanges + // is needed to let the horizontal scroll's native 'scroll' event fire and its + // own deferred chunkLoad flush before the cell activates. UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement, true, false, false, true); await wait(200); fix.detectChanges(); + await wait(200); + fix.detectChanges(); cell2 = grid.getCellByColumn(499, '49'); GridSelectionFunctions.verifyGridCellSelected(fix, cell2); @@ -703,6 +721,120 @@ describe('IgxGrid - Keyboard navigation #grid', () => { }); }); + describe('in virtualized grid with zoneless change detection', () => { + let fix; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VirtualGridComponent, NoopAnimationsModule + ], + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VirtualGridComponent); + }); + + it('should allow navigating first/last cell in column with home/end and Cntr key.', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(50); + fix.componentInstance.data = fix.componentInstance.generateData(500); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + + grid.verticalScrollContainer.addScrollTop(5000); + await wait(100); + await fix.whenStable(); + + let cell = grid.gridAPI.get_cell_by_index(101, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + await fix.whenStable(); + + UIInteractions.triggerKeyDownEvtUponElem('home', cell.nativeElement, true, false, false, true); + await wait(150); + await fix.whenStable(); + + let cell2 = grid.getCellByColumn(0, '0'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(0); + + cell = grid.gridAPI.get_cell_by_index(4, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + await fix.whenStable(); + + UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement, true, false, false, true); + await wait(200); + await fix.whenStable(); + + cell2 = grid.getCellByColumn(499, '49'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + }); + + it('should allow navigating horizontally virtualized cells with arrow keys.', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(50); + fix.componentInstance.data = fix.componentInstance.generateData(500); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + + let cell = grid.gridAPI.get_cell_by_index(0, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + await fix.whenStable(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement); + await wait(150); + await fix.whenStable(); + + let selectedCell = grid.getCellByColumn(0, '3'); + GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); + + cell = grid.gridAPI.get_cell_by_index(0, '3'); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement); + await wait(150); + await fix.whenStable(); + + selectedCell = grid.getCellByColumn(0, '2'); + GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); + }); + + it('should allow navigating horizontally virtualized cells with home/end keys.', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(50); + fix.componentInstance.data = fix.componentInstance.generateData(500); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + + let cell = grid.gridAPI.get_cell_by_index(0, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + await fix.whenStable(); + + UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement); + await wait(150); + await fix.whenStable(); + + let selectedCell = grid.getCellByColumn(0, '49'); + GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); + + cell = grid.gridAPI.get_cell_by_index(0, '49'); + UIInteractions.triggerKeyDownEvtUponElem('home', cell.nativeElement); + await wait(150); + await fix.whenStable(); + + selectedCell = grid.getCellByColumn(0, '0'); + GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); + }); + }); + describe('Group By navigation ', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index 327bdcab091..c6bb2a1157a 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -1,11 +1,12 @@ import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { provideZonelessChangeDetection } from '@angular/core'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, navigateWithGridScroll, setupGridScrollDetection, setupGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; import { CellType, IGridCellEventArgs, IgxColumnComponent, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; @@ -1580,16 +1581,12 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); - await wait(DEBOUNCE_TIME * 2); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); - await wait(DEBOUNCE_TIME * 2); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowLeft', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); @@ -1710,16 +1707,12 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); - await wait(DEBOUNCE_TIME * 2); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); - await wait(DEBOUNCE_TIME * 2); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowLeft', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); @@ -1767,19 +1760,13 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { await wait(); fix.detectChanges(); - GridFunctions.simulateGridContentKeydown(fix, 'End', false, false, true); - await wait(200); - fix.detectChanges(); - await wait(200); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'End', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value) .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); - GridFunctions.simulateGridContentKeydown(fix, 'Home', false, false, true); - await wait(200); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'Home', { ctrlKey: true }); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); @@ -1942,6 +1929,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { ] }]; await wait(DEBOUNCE_TIME); + await fix.whenStable(); fix.detectChanges(); const grid = fix.componentInstance.grid; @@ -1953,9 +1941,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow down - GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, grid, 'ArrowDown', { axis: 'vertical' }); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Phone'); @@ -1972,9 +1958,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow up - GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, grid, 'ArrowUp', { axis: 'vertical' }); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); @@ -1991,9 +1975,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, grid, 'ArrowRight', { axis: 'vertical' }); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Address'); @@ -2010,9 +1992,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow left - GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, grid, 'ArrowLeft', { axis: 'vertical' }); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); @@ -2049,9 +2029,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight'); // check next cell is active cell = grid.getCellByColumn(0, 'City'); @@ -2111,7 +2089,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { // check correct cell is active and is fully in view const lastCell = grid.gridAPI.get_cell_by_index(0, 'Address'); - expect(lastCell.active).toBe(true); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(800); let diff = lastCell.nativeElement.getBoundingClientRect().right - grid.tbody.nativeElement.getBoundingClientRect().right; expect(diff).toBe(0); @@ -2123,7 +2101,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { // first cell should be active and is fully in view firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); - expect(firstCell.active).toBe(true); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); expect(grid.headerContainer.getScroll().scrollLeft).toBe(0); diff = firstCell.nativeElement.getBoundingClientRect().left - grid.tbody.nativeElement.getBoundingClientRect().left; expect(diff).toBe(0); @@ -2420,16 +2398,13 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { UIInteractions.simulateClickAndSelectEvent(firstCell); fix.detectChanges(); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); - await wait(DEBOUNCE_TIME); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight'); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); expect(secondCell.componentInstance.active).toBeTruthy(); - GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); - fix.detectChanges(); + await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowDown'); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); @@ -2665,6 +2640,196 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { }); }); +describe('IgxGrid Multi Row Layout - Keyboard navigation in zoneless change detection #grid', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ColumnLayoutTestComponent], + providers: [ + IgxGridMRLNavigationService, + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ColumnLayoutTestComponent); + }); + + it(`should navigate to the last cell from the layout by pressing Home/End and Ctrl key + and keep same rowStart from the first selection when last cell spans more rows`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + setupGridScrollDetectionZoneless(fix, fix.componentInstance.grid); + // last cell from first layout + const lastCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateClickAndSelectEvent(lastCell); + await wait(); + await fix.whenStable(); + + GridFunctions.simulateGridContentKeydown(fix, 'End', false, false, true); + await wait(200); + await fix.whenStable(); + await wait(200); + await fix.whenStable(); + + expect(fix.componentInstance.selectedCell.value) + .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'Home', false, false, true); + await wait(200); + await fix.whenStable(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + clearGridSubs(); + }); + + it('should scroll active cell fully in view when navigating with arrow keys and row is partially visible.', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }, + { + group: 'group3', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + await fix.whenStable(); + + // focus 3rd row, first cell + let cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(2, 'Phone'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); + let diff = grid.gridAPI.get_cell_by_index(2, 'Phone') + .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + expect(diff).toBe(0); + + // focus 1st row, 2nd cell + cell = grid.gridAPI.get_cell_by_index(0, 'Phone'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); + diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') + .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; + expect(diff).toBe(0); + + // focus 3rd row, first cell + cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + // arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(2, 'Address'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); + diff = grid.gridAPI.get_cell_by_index(2, 'Address') + .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + expect(diff).toBe(0); + + // focus 1st row, Address + cell = grid.gridAPI.get_cell_by_index(0, 'Address'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + // arrow left + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(cell.active).toBe(true); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); + diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') + .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; + expect(diff).toBe(0); + }); +}); + @Component({ template: ` diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 07b9e63e6c7..64c616c5a9c 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing'; import { BehaviorSubject, Observable } from 'rxjs'; import { By } from '@angular/platform-browser'; @@ -15,7 +15,7 @@ import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabItemComponent, Igx import { IgxGridRowComponent } from './grid-row.component'; import { GRID_SCROLL_CLASS, GridFunctions } from '../../../test-utils/grid-functions.spec'; import { AsyncPipe } from '@angular/common'; -import { setElementSize, ymd } from '../../../test-utils/helper-utils.spec'; +import { setElementSize, waitForGridSettle, ymd } from '../../../test-utils/helper-utils.spec'; import { FilteringExpressionsTree, FilteringLogic, getComponentSize, GridColumnDataType, IgxNumberFilteringOperand, IgxStringFilteringOperand, ISortingExpression, ɵSize, SortingDirection } from 'igniteui-angular/core'; import { IgxPaginatorComponent, IgxPaginatorContentDirective } from 'igniteui-angular/paginator'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../src/grid-base.directive'; @@ -2089,9 +2089,9 @@ describe('IgxGrid Component Tests #grid', () => { const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); grid.navigateTo(50, 16); - fix.detectChanges(); - await wait(100); - fix.detectChanges(); + // navigateTo scrolls the virtualized grid across several async render passes; + // wait for the target cell to render instead of racing it with a fixed delay. + await waitForGridSettle(fix, () => !!grid.gridAPI.get_cell_by_index(50, 'col16')); expect(headerRowElement.getAttribute('aria-rowindex')).toBe('1'); expect(grid.nativeElement.getAttribute('aria-rowcount')).toBe('81'); @@ -2104,6 +2104,40 @@ describe('IgxGrid Component Tests #grid', () => { expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); }); + + describe('Zoneless change detection', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + }); + + it('should set correct aria attributes related to total rows/cols count and indexes', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(80, 20); + fix.detectChanges(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const gridHeader = GridFunctions.getGridHeader(grid); + const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); + + grid.navigateTo(50, 16); + await wait(100); + await fix.whenStable(); + + expect(headerRowElement.getAttribute('aria-rowindex')).toBe('1'); + expect(grid.nativeElement.getAttribute('aria-rowcount')).toBe('81'); + expect(grid.nativeElement.getAttribute('aria-colcount')).toBe('20'); + + const cell = grid.gridAPI.get_cell_by_index(50, 'col16'); + // The following attributes indicate to assistive technologies which portions + // of the content are displayed in case not all are rendered, + // such as with the built-in virtualization of the grid. 1-based index. + expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); + expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); + }); + }); }); describe('IgxGrid - min/max width constraints rules', () => { diff --git a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts index ba85f7020db..65422e43a7f 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { formatNumber } from '@angular/common' import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -4040,6 +4040,74 @@ describe('IgxGrid - GroupBy #grid', () => { return fix.whenStable(); }; }); + +describe('IgxGrid - GroupBy zoneless change detection #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + GroupableGridComponent + ], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + })); + + it('should update horizontal virtualization state correctly when data row views are re-used from cache', async () => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + await fix.whenStable(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + await fix.whenStable(); + + grid.toggleAllGroupRows(); + await wait(100); + await fix.whenStable(); + + grid.headerContainer.getScroll().scrollLeft = 1000; + await fix.whenStable(); + + const gridScrLeft = grid.headerContainer.getScroll().scrollLeft; + await wait(100); + await fix.whenStable(); + + grid.toggleAllGroupRows(); + await fix.whenStable(); + await wait(); + + let dataRows = grid.dataRowList.toArray(); + dataRows.forEach(dr => { + const virtualization = dr.virtDirRow; + const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; + expect(virtualization.state.startIndex).toBe(expectedStartIndex); + const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); + expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); + }); + + // scroll down so vertical virtualization recycles row views from cache + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + await wait(100); + await fix.whenStable(); + + dataRows = grid.dataRowList.toArray(); + grid['_restoreVirtState'](dataRows[7]); + await wait(100); + await fix.whenStable(); + + // recycled rows must keep the horizontal virtualization state restored in zoneless mode + dataRows.forEach(dr => { + const virtualization = dr.virtDirRow; + const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; + expect(virtualization.state.startIndex).toBe(expectedStartIndex); + const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); + expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); + }); + }); +}); + @Component({ template: ` { setupGridScrollDetection(fix, grid); grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); await wait(DEBOUNCE_TIME); + await fix.whenStable(); fix.detectChanges(); const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'CompanyName'); UIInteractions.simulateClickAndSelectEvent(targetCellElement); - fix.detectChanges(); + const activeNodeChange = waitForActiveNodeChange(grid); UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); - await waitForActiveNodeChange(grid); - fix.detectChanges(); + + await dispatchGridScrollEvents(fix, grid, { waitMs: DEBOUNCE_TIME, waitForChunkLoad: true }); + await activeNodeChange; const fRow = grid.gridAPI.get_row_by_index(0); expect(fRow).not.toBeUndefined(); @@ -1276,6 +1279,158 @@ describe('IgxGrid Master Detail #grid', () => { }); }); +describe('IgxGrid Master Detail zoneless change detection #grid', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DefaultGridMasterDetailComponent, + AllExpandedGridMasterDetailComponent + ], + providers: [ + provideZonelessChangeDetection(), + IgxGridMRLNavigationService, + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }).compileComponents(); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to the last data cell in the grid using Ctrl + End', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + await fix.whenStable(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + await fix.whenStable(); + + const activeNodeChange = firstValueFrom(grid.activeNodeChange); + UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); + await activeNodeChange; + await fix.whenStable(); + + const lastRow = grid.gridAPI.get_row_by_index(52); + expect(lastRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, lastRow.nativeElement)).toBeTruthy(); + expect((lastRow.cells as QueryList).last.active).toBeTruthy(); + }); + + it('should navigate to the last data row using Ctrl + ArrowDown when all rows are expanded', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + await fix.whenStable(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const lastRow = grid.gridAPI.get_row_by_index(52); + expect(lastRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, lastRow.nativeElement)).toBeTruthy(); + expect((lastRow.cells as QueryList).first.active).toBeTruthy(); + }); + + it('should navigate to the first data row using Ctrl + ArrowUp when all rows are expanded', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'CompanyName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await waitForActiveNodeChange(grid); + await fix.whenStable(); + + const firstRow = grid.gridAPI.get_row_by_index(0); + expect(firstRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, firstRow.nativeElement)).toBeTruthy(); + expect((firstRow.cells as QueryList).last.active).toBeTruthy(); + }); + + it('should navigate to the first data cell in the grid using Ctrl + Home', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + await fix.whenStable(); + + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + const fRow = grid.gridAPI.get_row_by_index(0); + expect(fRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, fRow.nativeElement)).toBeTruthy(); + expect((fRow.cells as QueryList).first.active).toBeTruthy(); + }); + + it('should navigate to the first/last row using Ctrl+ArrowUp/ArrowDown when focus is on the detail row container', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await fix.whenStable(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + + let row = grid.gridAPI.get_row_by_index(0); + let detailRow = GridFunctions.getMasterRowDetail(row); + UIInteractions.simulateClickAndSelectEvent(detailRow); + await fix.whenStable(); + + GridFunctions.verifyMasterDetailRowFocused(detailRow); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + row = grid.gridAPI.get_row_by_index(0); + detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + await fix.whenStable(); + + row = grid.gridAPI.get_row_by_index(0); + detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + }); +}); + @Component({ template: ` diff --git a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts index 2109db966bf..8dca2d8f7e8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { provideZonelessChangeDetection } from '@angular/core'; import { By } from '@angular/platform-browser'; import { IgxGridComponent } from './public_api'; import { BasicGridSearchComponent } from '../../../test-utils/grid-base-components.spec'; @@ -1074,6 +1075,7 @@ describe('IgxGrid - search API #grid', () => { it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { grid.height = '500px'; + await fix.whenStable(); fix.detectChanges(); grid.groupBy({ @@ -1082,14 +1084,21 @@ describe('IgxGrid - search API #grid', () => { ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); - fix.detectChanges(); + await fix.whenStable(); + fix.detectChanges(); + grid.findNext('a'); await wait(); - fix.detectChanges(); + await fix.whenStable(); + const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); (grid as any).scrollTo(9, 0); - await firstValueFrom(grid.verticalScrollContainer.chunkLoad); - fix.detectChanges(); + fix.detectChanges(); + await wait(); + await fix.whenStable(); + fix.detectChanges(); + await chunkLoad; + const row = grid.gridAPI.get_row_by_index(9); const spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); expect(spans.length).toBe(5); @@ -1229,6 +1238,43 @@ describe('IgxGrid - search API #grid', () => { }); }); + describe('GroupableGrid in zoneless change detection - ', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + fix = TestBed.createComponent(GroupableGridSearchComponent); + fix.detectChanges(); + component = fix.componentInstance; + grid = component.grid; + fixNativeElement = fix.debugElement.nativeElement; + }); + + it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { + grid.height = '500px'; + await fix.whenStable(); + + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + await fix.whenStable(); + grid.findNext('a'); + await wait(); + await fix.whenStable(); + + (grid as any).scrollTo(9, 0); + await firstValueFrom(grid.verticalScrollContainer.chunkLoad); + await wait(); + await fix.whenStable(); + const row = grid.gridAPI.get_row_by_index(9); + const spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(spans.length).toBe(5); + }); + }); + const isInView = (index, state: IForOfState): boolean => index > state.startIndex && index <= state.startIndex + state.chunkSize; const getSpans = () => fixNativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts index bfb4dd5b95a..a5cc8c33790 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -252,8 +252,8 @@ describe('IgxGrid - Zoneless Change Detection #grid', () => { * zone.onStable.pipe(first()).subscribe(), so the event was never raised * after a horizontal scroll in a zoneless consumer app. * - * Fix: apply the same isZonelessChangeDetection() guard used in - * verticalScrollHandler() and call emit() directly. + * Fix: emit() is no longer gated behind zone lifecycle observables; deferred + * render work goes through the central runAfterRenderOnce() helper instead. */ it('should emit parentVirtDir.chunkLoad after horizontal scroll in zoneless mode', fakeAsync(() => { const fix = TestBed.createComponent(ZonelessWideGridComponent); @@ -311,8 +311,8 @@ describe('IgxGrid - Zoneless Change Detection #grid', () => { * then gates the re-measurement behind zone.onStable.pipe(first()).subscribe(). * In NoopNgZone that subscription never fires, so the method silently does nothing. * - * Fix: detect zoneless with isZonelessChangeDetection() and call - * cdr.detectChanges() + autoSizeColumnsInView() directly. + * Fix: the re-measurement is scheduled through the central runAfterRenderOnce() + * helper (afterNextRender), which works in both zoned and zoneless apps. * * Note on hasColumnsToAutosize: after the initial render ChromeHeadless measures * real header widths > 0, so col.autoSize becomes a number and col.width returns @@ -340,8 +340,9 @@ describe('IgxGrid - Zoneless Change Detection #grid', () => { * In NoopNgZone that subscription never fires, so columns are never re-measured * when the header virtual scroll data changes (column visibility toggle, H-scroll). * - * Fix: detect zoneless with isZonelessChangeDetection() and call - * autoSizeColumnsInView() directly after detectChanges(). + * Fix: autoSizeColumnsInView() is scheduled through the central + * runAfterRenderOnce() helper (afterNextRender), which works in both + * zoned and zoneless apps. * * Setup: hide then show a column, which causes headerContainer.dataChanged to emit. * The spy is placed between the two visibility changes so only the "show" emission diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts index 0226ea1eca4..ec6f875435e 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts @@ -165,13 +165,13 @@ export class IgxHierarchicalGridNavigationService extends IgxGridNavigationServi this._pendingNavigation = true; const scrollableGrid = isNext ? this.getNextScrollableDown(this.grid) : this.getNextScrollableUp(this.grid); scrollableGrid.grid.verticalScrollContainer.recalcUpdateSizes(); - scrollableGrid.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); scrollableGrid.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { this._pendingNavigation = false; if (cb) { cb(); } }); + scrollableGrid.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); } else { if (cb) { cb(); @@ -221,10 +221,10 @@ export class IgxHierarchicalGridNavigationService extends IgxGridNavigationServi return; } const positionInfo = this.getElementPosition(childGrid.nativeElement, false); - this.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); this.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { childGrid.navigation.navigateToChildGrid(pathToChildGrid, cb); }); + this.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); }); } diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index 3e745261833..b0372813124 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -1,21 +1,35 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, ViewChild, DebugElement, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, DebugElement, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; import { wait, UIInteractions, waitForSelectionChange } from '../../../test-utils/ui-interactions.spec'; import { IgxRowIslandComponent } from './row-island.component'; import { By } from '@angular/platform-browser'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; -import { clearGridSubs, setupHierarchicalGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection, setupHierarchicalGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; import { IPathSegment } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; +import { filter, firstValueFrom } from 'rxjs'; +import { IGridCreatedEventArgs } from './events'; const DEBOUNCE_TIME = 60; const GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; const GRID_FOOTER_CLASS = '.igx-grid__tfoot'; +const settleGridScrollEvents = async (fixture, ...grids) => { + for (const grid of grids) { + await dispatchGridScrollEvents(fixture, grid, { waitMs: DEBOUNCE_TIME }); + } +}; + +const waitForInitializedGrid = (rowIsland: IgxRowIslandComponent, parentID: any) => + firstValueFrom(rowIsland.gridInitialized.pipe(filter((event: IGridCreatedEventArgs) => event.parentID === parentID))); + +const getRowIslandByKey = (rowIslands, key: string) => + rowIslands.toArray().find((rowIsland: IgxRowIslandComponent) => rowIsland.key === key); + describe('IgxHierarchicalGrid Navigation', () => { let fixture; let hierarchicalGrid: IgxHierarchicalGridComponent; @@ -156,8 +170,7 @@ describe('IgxHierarchicalGrid Navigation', () => { const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); - fixture.detectChanges(); - await wait(); + await settleGridScrollEvents(fixture, childGrid, hierarchicalGrid); // verify selection in child. const selectedCell = fixture.componentInstance.selectedCell; @@ -185,8 +198,7 @@ describe('IgxHierarchicalGrid Navigation', () => { const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); - await wait(DEBOUNCE_TIME * 3); - fixture.detectChanges(); + await settleGridScrollEvents(fixture, childGrid, hierarchicalGrid); const selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.value).toEqual(0); @@ -368,9 +380,10 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.dataRowList.first.cells.first; GridFunctions.focusCell(fixture, parentCell); + const selectionChange = waitForSelectionChange(hierarchicalGrid); UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); - fixture.detectChanges(); - await waitForSelectionChange(hierarchicalGrid); + await settleGridScrollEvents(fixture, hierarchicalGrid); + await selectionChange; const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); const selectedCell = fixture.componentInstance.selectedCell; @@ -526,8 +539,7 @@ describe('IgxHierarchicalGrid Navigation', () => { const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, child1); // second child row should be in view const sChildRowCell = child1.getRowByIndex(2).cells[0]; @@ -537,8 +549,7 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - fixture.detectChanges(); + await settleGridScrollEvents(fixture, child1); selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.row.index).toBe(0); @@ -683,8 +694,7 @@ describe('IgxHierarchicalGrid Navigation', () => { // navigate up const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, nestedChild, child, hierarchicalGrid); let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -699,8 +709,7 @@ describe('IgxHierarchicalGrid Navigation', () => { // navigate up into parent UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, nestedChild, child, hierarchicalGrid); nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -721,8 +730,7 @@ describe('IgxHierarchicalGrid Navigation', () => { const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, nestedChild, child); // check if parent has scrolled down to show focused cell. expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); @@ -752,8 +760,7 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, parentCell); UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); - await wait(DEBOUNCE_TIME); - fixture.detectChanges(); + await settleGridScrollEvents(fixture, child, hierarchicalGrid); const nestedChild = child.gridAPI.getChildGrids(false)[5]; const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); @@ -791,8 +798,7 @@ describe('IgxHierarchicalGrid Navigation', () => { const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, child2, child1, hierarchicalGrid); const lastCellPrevRI = child1.dataRowList.last.cells.first; @@ -832,10 +838,8 @@ describe('IgxHierarchicalGrid Navigation', () => { // Arrow Up into prev child grid UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); - const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + await settleGridScrollEvents(fixture, child2, hierarchicalGrid); const child2Cell = child2.dataRowList.last.cells.first; expect(child2Cell.selected).toBe(true); @@ -875,8 +879,7 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(childLastCell.length).toBe(0); UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME * 2); + await settleGridScrollEvents(fixture, childGrid2, childGrid, hierarchicalGrid); childLastCell = childGrid.selectedCells; expect(childLastCell.length).toBe(1); @@ -925,9 +928,7 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, parentCell); UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); - - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, hierarchicalGrid); // last cell in child should be focused const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); @@ -944,16 +945,14 @@ describe('IgxHierarchicalGrid Navigation', () => { const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; firstChildGrid.verticalScrollContainer.scrollTo(9); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, firstChildGrid); const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); GridFunctions.focusCell(fixture, firstChildCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, firstChildGrid, secondChildGrid, hierarchicalGrid); const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); @@ -1018,12 +1017,469 @@ describe('IgxHierarchicalGrid Navigation', () => { rowID: 5 }; + const rootRowIsland = getRowIslandByKey(hierarchicalGrid.childLayoutList, targetRoot.rowIslandKey); + const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); + const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); + const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); + await settleGridScrollEvents(fixture, hierarchicalGrid); + const childGrid = (await childGridInitialized).grid.nativeElement; + expect(childGrid).not.toBe(undefined); + await settleGridScrollEvents(fixture, hierarchicalGrid); + const childGridNested = (await nestedChildGridInitialized).grid.nativeElement; + expect(childGridNested).not.toBe(undefined); + + const parentBottom = childGrid.getBoundingClientRect().bottom; + const parentTop = childGrid.getBoundingClientRect().top; + // check it's in view within its parent + expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); + }); + }); + + describe('IgxHierarchicalGrid Basic Navigation in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should allow navigating to end in child grid when child grid target row moves outside the parent view port.', async () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; + GridFunctions.focusCell(fixture, childCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); + await wait(); + await fixture.whenStable(); + + // verify selection in child. + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toEqual(9); + expect(selectedCell.column.field).toMatch('childData2'); + + // parent should be scrolled down + const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight * 5); + }); + + it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const horizontalScrDir = childGrid.dataRowList.toArray()[0].virtDirRow; + horizontalScrDir.scrollTo(6); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const childLastCell = childGrid.dataRowList.toArray()[9].cells.toArray()[3]; + GridFunctions.focusCell(fixture, childLastCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); + await wait(DEBOUNCE_TIME * 3); + await fixture.whenStable(); + + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(0); + expect(selectedCell.column.index).toBe(0); + expect(selectedCell.row.index).toBe(0); + + // check if child row is in view of parent. + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const rowElem = childGrid.gridAPI.get_row_by_index(selectedCell.row.index); + const rowOffsets = rowElem.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top).toBeGreaterThanOrEqual(gridOffsets.top); + expect(rowOffsets.bottom).toBeLessThanOrEqual(gridOffsets.bottom); + }); + + it('should move activation to last data cell in grid when ctrl+end is used.', async () => { + const parentCell = hierarchicalGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); + await waitForSelectionChange(hierarchicalGrid); + await fixture.whenStable(); + + const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(lastDataCell.row.index); + expect(selectedCell.column.index).toBe(lastDataCell.column.index); + }); + + it('should skip nested child grids that have no data when navigating up/down', async () => { + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + child1.height = '150px'; + await wait(); + await fixture.whenStable(); + const row = child1.dataRowList.first as IgxHierarchicalRowComponent; + row.toggle(); + await wait(); + await fixture.whenStable(); + // set nested child to not have data + const subChild = child1.gridAPI.getChildGrids(false)[0]; + subChild.data = []; + await wait(); + await fixture.whenStable(); + + const fchildRowCell = row.cells.first; + GridFunctions.focusCell(fixture, fchildRowCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + // second child row should be in view + const sChildRowCell = child1.getRowByIndex(2).cells[0]; + let selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toBe(sChildRowCell.value); + + expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(0); + expect(child1.verticalScrollContainer.getScroll().scrollTop).toBe(0); + }); + }); + + describe('IgxHierarchicalGrid Complex Navigation in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridTestComplexComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('in case prev cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + // scroll parent so that child top is not in view + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + hierarchicalGrid.verticalScrollContainer.addScrollTop(300); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const nestedChild = child.gridAPI.getChildGrids(false)[0]; + const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; + + GridFunctions.focusCell(fixture, nestedChildCell); + let oldScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + await fixture.whenStable(); + + // navigate up + const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; + let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + const elemHeight = nestedChildCell.intRow.nativeElement.clientHeight; + // check if parent of parent has been scroll up so that the focused cell is in view + expect(oldScrTop - currScrTop).toEqual(elemHeight); + oldScrTop = currScrTop; + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(0); + + // navigate up into parent + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; + currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(oldScrTop - currScrTop).toBeGreaterThanOrEqual(100); + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(0); + }); + + it('in case next cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const nestedChild = child.gridAPI.getChildGrids(false)[0]; + const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; + + // navigate down in nested child + GridFunctions.focusCell(fixture, nestedChildCell); + + const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + // check if parent has scrolled down to show focused cell. + expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); + const nextCell = nestedChild.dataRowList.toArray()[2].cells.toArray()[0]; + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(2); + }); + + it('should allow navigating up from parent into nested child grid', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const lastIndex = child.dataView.length - 1; + child.verticalScrollContainer.scrollTo(lastIndex); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + child.verticalScrollContainer.scrollTo(lastIndex); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const nestedChild = child.gridAPI.getChildGrids(false)[5]; + const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); + expect(lastCell.selected).toBe(true); + expect(lastCell.active).toBe(true); + expect(lastCell.row.index).toBe(4); + }); + }); + + describe('IgxHierarchicalGrid sibling row islands Navigation in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should allow navigating up between sibling child grids.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(); + await fixture.whenStable(); + + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + const child2Cell = child2.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, child2Cell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const lastCellPrevRI = child1.dataRowList.last.cells.first; + + expect(lastCellPrevRI.active).toBe(true); + expect(lastCellPrevRI.selected).toBe(true); + expect(lastCellPrevRI.rowIndex).toBe(9); + }); + + it('should navigate up from parent row to the correct child sibling.', async () => { + const parentCell = hierarchicalGrid.dataRowList.toArray()[1].cells.first; + GridFunctions.focusCell(fixture, parentCell); + + // Arrow Up into prev child grid + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + const child2Cell = child2.dataRowList.last.cells.first; + expect(child2Cell.selected).toBe(true); + expect(child2Cell.active).toBe(true); + expect(child2Cell.rowIndex).toBe(9); + }); + + it('should navigate to last cell in previous child using Arrow Up from last cell of sibling with more columns', async () => { + const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + childGrid2.dataRowList.first.virtDirRow.scrollTo(7); + await wait(); + await fixture.whenStable(); + + const child2LastCell = childGrid2.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, child2LastCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + let childLastCell = childGrid.selectedCells; + expect(childLastCell.length).toBe(0); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); await wait(DEBOUNCE_TIME * 2); + await fixture.whenStable(); + + childLastCell = childGrid.selectedCells; + expect(childLastCell.length).toBe(1); + expect(childLastCell[0].active).toBeTruthy(); + }); + }); + + describe('IgxHierarchicalGrid Smaller Child Navigation in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridSmallerChildComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to last cell in next row for child grid using Arrow Up from last cell of parent with more columns', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(); + await fixture.whenStable(); + + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'Col2'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + // last cell in child should be focused + const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'ProductName'); + + expect(childLastCell.selected).toBe(true); + expect(childLastCell.active).toBe(true); + }); + + it('should navigate to last cell in next child using Arrow Down from last cell of previous child with more columns', async () => { + const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + + firstChildGrid.verticalScrollContainer.scrollTo(9); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); + GridFunctions.focusCell(fixture, firstChildCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + + + const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(secondChildCell.selected).toBe(true); + expect(secondChildCell.active).toBe(true); + }); + }); + + describe('IgxHierarchicalGrid Navigation API in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, + provideZonelessChangeDetection() + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); fixture.detectChanges(); - const childGrid = hierarchicalGrid.gridAPI.getChildGrid([targetRoot]).nativeElement; + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to exact nested child grid with navigateToChildGrid.', async() => { + hierarchicalGrid.expandChildren = false; + await wait(DEBOUNCE_TIME); + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.childLayoutList.toArray()[0].primaryKey = 'ID'; + await wait(DEBOUNCE_TIME); + await fixture.whenStable(); + const targetRoot: IPathSegment = { + rowKey: 10, + rowIslandKey: 'childData', + rowID: 10 + }; + const targetNested: IPathSegment = { + rowKey: 5, + rowIslandKey: 'childData2', + rowID: 5 + }; + + const rootRowIsland = getRowIslandByKey(hierarchicalGrid.childLayoutList, targetRoot.rowIslandKey); + const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); + const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); + const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); + await settleGridScrollEvents(fixture, hierarchicalGrid); + const childGrid = (await childGridInitialized).grid.nativeElement; expect(childGrid).not.toBe(undefined); - const childGridNested = hierarchicalGrid.gridAPI.getChildGrid([targetRoot, targetNested]).nativeElement; + await settleGridScrollEvents(fixture, hierarchicalGrid); + const childGridNested = (await nestedChildGridInitialized).grid.nativeElement; expect(childGridNested).not.toBe(undefined); const parentBottom = childGrid.getBoundingClientRect().bottom; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index 6575d4812e9..15a5113f0d0 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -1,12 +1,12 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; import { IgxRowIslandComponent } from './row-island.component'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { By } from '@angular/platform-browser'; import { first, delay } from 'rxjs/operators'; -import { setupHierarchicalGridScrollDetection, clearGridSubs } from '../../../test-utils/helper-utils.spec'; +import { setupHierarchicalGridScrollDetection, clearGridSubs, setGridVerticalScrollTop } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { HierarchicalGridFunctions } from '../../../test-utils/hierarchical-grid-functions.spec'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; @@ -213,9 +213,7 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { fixture.detectChanges(); // Scroll to bottom - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; - await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - fixture.detectChanges(); + await setGridVerticalScrollTop(fixture, hierarchicalGrid, 5000); // Expand two rows at the bottom (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); await wait(); @@ -226,14 +224,10 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { fixture.detectChanges(); // Scroll to top to make sure top. - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; - await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - fixture.detectChanges(); + await setGridVerticalScrollTop(fixture, hierarchicalGrid, 0); // Scroll to somewhere in the middle and make sure scroll position stays when expanding/collapsing. - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; - await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - fixture.detectChanges(); + await setGridVerticalScrollTop(fixture, hierarchicalGrid, 1250); const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; const topOffset = hierarchicalGrid.navigation.containerTopOffset; const secondRow = hierarchicalGrid.rowList.toArray()[2]; @@ -548,3 +542,126 @@ export class IgxHierarchicalGridNoScrollTestComponent extends IgxHierarchicalGri this.data = this.generateData(3, 0); } } + +describe('IgxHierarchicalGrid Virtualization zoneless change detection #hGrid', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + let originalTimeout: number; + + beforeEach(() => { + // Scroll-heavy zoneless scenarios settle over many spaced render passes and can + // exceed the default budget on slower machines. + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000; + }); + + afterEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent + ], + providers: [ + provideZonelessChangeDetection(), + IgxGridNavigationService, + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }).compileComponents(); + })); + + it('should not lose scroll position after expanding a row when there are already expanded rows above', async () => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + await fixture.whenStable(); + hierarchicalGrid = fixture.componentInstance.hgrid; + + (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + (hierarchicalGrid.dataRowList.toArray()[1].nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; + await Promise.race([chunkLoad, wait(500)]); + await fixture.whenStable(); + + (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + (hierarchicalGrid.dataRowList.toArray()[4].nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; + await Promise.race([chunkLoad, wait(500)]); + await fixture.whenStable(); + + chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; + await Promise.race([chunkLoad, wait(500)]); + await fixture.whenStable(); + + const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; + const topOffset = hierarchicalGrid.navigation.containerTopOffset; + const secondRow = hierarchicalGrid.rowList.toArray()[2]; + + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + hierarchicalGrid.navigation.containerTopOffset - + topOffset + ).toBeLessThanOrEqual(1); + + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + hierarchicalGrid.navigation.containerTopOffset - + topOffset + ).toBeLessThanOrEqual(1); + }); + + it('should retain expansion state when scrolling', async () => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + await fixture.whenStable(); + hierarchicalGrid = fixture.componentInstance.hgrid; + + const firstRow = hierarchicalGrid.dataRowList.toArray()[0] as IgxHierarchicalRowComponent; + (firstRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + await fixture.whenStable(); + expect(firstRow.expanded).toBeTruthy(); + + const verticalScroll = hierarchicalGrid.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + + // scroll down so the expanded row is recycled out of view + let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + elem.scrollTop = 1000; + await Promise.race([chunkLoad, wait(500)]); + await fixture.whenStable(); + expect(firstRow.expanded).toBeFalsy(); + + // scroll back to top + chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + elem.scrollTop = 0; + await Promise.race([chunkLoad, wait(500)]); + await fixture.whenStable(); + expect(firstRow.expanded).toBeTruthy(); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts index 7c2caa4887d..16b57a0306a 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts @@ -4,6 +4,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestBaseComponent } from '../../../test-utils/pivot-grid-samples.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { waitForGridSettle } from '../../../test-utils/helper-utils.spec'; import { IgxPivotGridComponent } from './pivot-grid.component'; import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; import { DebugElement } from '@angular/core'; @@ -331,8 +332,12 @@ describe('IgxPivotGrid - Keyboard navigation #pivotGrid', () => { const gridContent = GridFunctions.getGridContent(fixture); UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); - await wait(30); - fixture.detectChanges(); + // Horizontal navigation scrolls the virtualized columns before activating the + // next cell; wait for that to settle instead of racing it with a fixed delay. + await waitForGridSettle(fixture, () => { + const active = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); + return active.length === 1 && active[0].componentInstance.column.field === 'Stanley-UnitPrice'; + }); activeCells = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); expect(activeCells.length).toBe(1); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts index 229924034de..fb64d627f93 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -12,7 +12,7 @@ import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; import { IgxColumnComponent } from 'igniteui-angular/grids/core'; import { FilterMode, GridPagingMode, GridSummaryPosition } from 'igniteui-angular/grids/core'; import { WatchChanges } from 'igniteui-angular/grids/core'; -import { cloneArray, ColumnType, DataUtil, DefaultDataCloneStrategy, GridColumnDataType, GridSummaryCalculationMode, IDataCloneStrategy, IFilteringExpressionsTree, IFilteringOperation, IFilteringStrategy, ISortingExpression, OverlaySettings, resizeObservable, ɵSize, SortingDirection, IgxOverlayOutletDirective } from 'igniteui-angular/core'; +import { cloneArray, ColumnType, DataUtil, DefaultDataCloneStrategy, GridColumnDataType, GridSummaryCalculationMode, IDataCloneStrategy, IFilteringExpressionsTree, IFilteringOperation, IFilteringStrategy, ISortingExpression, OverlaySettings, resizeObservable, runAfterRenderOnce, ɵSize, SortingDirection, IgxOverlayOutletDirective } from 'igniteui-angular/core'; import { IGridEditEventArgs, ICellPosition, @@ -2153,7 +2153,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - this.runAfterRender(() => { + runAfterRenderOnce(this.injector, () => { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts index 0db6710b545..33a773cf136 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts @@ -1,4 +1,3 @@ -import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -3526,63 +3525,3 @@ describe('IgxPivotGrid #pivotGrid', () => { }); }); }); - -describe('IgxPivotGrid - Zoneless Change Detection #pivotGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - IgxPivotGridTestBaseComponent - ], - providers: [ - IgxGridNavigationService, - provideZonelessChangeDetection() - ] - }).compileComponents(); - })); - - it('should auto-size row dimension when width is set to auto in zoneless mode', fakeAsync(() => { - const fixture = TestBed.createComponent(IgxPivotGridTestBaseComponent); - fixture.componentInstance.pivotConfigHierarchy = { - columns: [{ - memberName: 'Country', - enabled: true - }], - rows: [{ - memberName: 'All', - memberFunction: () => 'All', - enabled: true, - width: 'auto', - childLevel: { - memberName: 'ProductCategory', - memberFunction: (data) => data.ProductCategory, - enabled: true - } - }], - values: [{ - member: 'UnitPrice', - aggregate: { - aggregator: IgxPivotNumericAggregate.sum, - key: 'SUM', - label: 'Sum', - }, - enabled: true, - dataType: 'currency' - }], - filters: [] - }; - - fixture.detectChanges(); - const pivotGrid = fixture.componentInstance.pivotGrid; - const rowDimension = pivotGrid.pivotConfiguration.rows[0]; - - expect(rowDimension.autoWidth).toBeUndefined(); - - tick(16); - - expect(rowDimension.width).toBe('auto'); - expect(rowDimension.autoWidth).toBeGreaterThan(0); - expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(rowDimension.autoWidth); - })); -}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 1fc344f8240..12ea2db0479 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -4,9 +4,9 @@ import { IgxTreeGridComponent } from './public_api'; import { IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { CellType } from 'igniteui-angular/grids/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; @@ -423,8 +423,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 5; i < 9; i++) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); - await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -433,11 +432,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 9; i > 0; i--) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); - if (i <= 4) - await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); - else - await wait(); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -566,16 +561,14 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); - await wait(100); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); - await wait(100); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -667,8 +660,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { let newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); - await wait(DEBOUNCETIME * 2); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); @@ -676,8 +668,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { expect( treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); - await wait(DEBOUNCETIME * 2); - fix.detectChanges(); + await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); expect(newCell.editMode).toBe(true); @@ -846,4 +837,457 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); }); }); + + describe('Navigation with scrolls in zoneless change detection', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let gridContent: DebugElement; + const treeColumns = ['ID', 'Name', 'HireDate', 'Age', 'OnPTO']; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + fix = TestBed.createComponent(IgxTreeGridWithScrollsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should navigate with arrow Up and Down keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + const firstCell: CellType = treeGrid.gridAPI.get_cell_by_index(5, 'ID'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, treeGrid.gridAPI.get_cell_by_index(5, 'ID')); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + for (let i = 5; i < 9; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + const chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await chunkLoad; + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + for (let i = 9; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + let chunkLoad: Promise | null = null; + if (i <= 4) { + chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); + } + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + if (chunkLoad) { + await chunkLoad; + } else { + await wait(); + } + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(14); + }); + + it('should navigate with arrow Left and Right', async () => { + const firstCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); + spyOn(treeGrid.selected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + for (let i = 0; i < treeColumns.length - 1; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i + 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(i + 2); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(); + await fix.whenStable(); + + let lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + + for (let i = treeColumns.length - 1; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - i); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(); + await fix.whenStable(); + + lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - 1); + }); + + it('should move to the top/bottom cell when navigate with Ctrl + arrow Up/Down', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(100); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(100); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should move to the top left/bottom right cell when navigate with Ctrl + Home/End keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(2, treeColumns[2]); + + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); + await wait(100); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); + await wait(100); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should allow pageup/pagedown navigation when the treeGrid is focused', async () => { + const cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('PageDown', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + expect(treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(100); + + UIInteractions.triggerEventHandlerKeyDown('PageUp', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + expect(treeGrid.headerContainer.getScroll().scrollTop).toEqual(0); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + }); + + it('should change editable cell and scroll when Tab and Shift + Tab keys are pressed', async () => { + treeGrid.getColumnByName('ID').editable = true; + treeGrid.getColumnByName('Name').editable = true; + treeGrid.getColumnByName('HireDate').editable = true; + treeGrid.getColumnByName('Age').editable = true; + treeGrid.getColumnByName('OnPTO').editable = true; + await fix.whenStable(); + + const firstCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[2]); + UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); + expect(firstCell.editMode).toBe(true); + + for (let i = 2; i < treeColumns.length - 1; i++) { + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + const cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i + 1]); + expect(cell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + await wait(DEBOUNCETIME * 2); + await fix.whenStable(); + + let newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + expect(newCell.editMode).toBe(true); + expect(treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + await wait(DEBOUNCETIME * 2); + await fix.whenStable(); + + newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); + expect(newCell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + + for (let i = 4; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i - 1]); + expect(cell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + }); + + it('should navigate with arrow Left key when there is a pinned column', async () => { + treeGrid.getColumnByName('HireDate').pinned = true; + await fix.whenStable(); + + const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; + const firstCell = treeGrid.gridAPI.get_cell_by_index(3, 'HireDate'); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + const lastCell = treeGrid.gridAPI.get_cell_by_index(3, columns[4]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + for (let i = 4; i > 0 ; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(0); + }); + + it('should navigate with arrow Right key when there is a pinned column', async () => { + treeGrid.getColumnByName('HireDate').pinned = true; + await fix.whenStable(); + + const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; + const firstCell = treeGrid.gridAPI.get_cell_by_index(0, 'HireDate'); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + let newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[4]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + const scrollLeft = treeGrid.headerContainer.getScroll().scrollLeft; + expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + for (let i = 0; i < columns.length - 1; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i + 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(scrollLeft); + }); + + it('should move to the leftmost/rightmost cell when navigate with Ctrl + arrow Left/Right keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[1]); + + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, false, false, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + await wait(DEBOUNCETIME); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(4); + }); + + it('should expand/collapse row when Alt + arrow Left/Right keys are pressed', async () => { + treeGrid.width = '400px'; + await wait(DEBOUNCETIME); + await fix.whenStable(); + treeGrid.headerContainer.scrollTo(4); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + const cell = treeGrid.gridAPI.get_cell_by_index(3, 'OnPTO'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[3]); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[3]); + }); + + it('should select correct cells after expand/collapse row', async () => { + let rows; + let cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + rows = TreeGridFunctions.getAllRows(fix); + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 9, 'ID', false); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[7]); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'ID', 'Name', true); + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'Name', 'ID', false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + await fix.whenStable(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[6]); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + }); + }); }); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index 71d8f5bb4ae..f687163eeca 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -8,10 +8,10 @@ import { IgxTreeGridSummariesScrollingComponent, IgxTreeGridSummariesKeyScroliingComponent } from '../../../test-utils/tree-grid-components.spec'; -import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, setupGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { IgxTreeGridComponent } from './tree-grid.component'; import { IgxSummaryRow, IgxTreeGridRow } from 'igniteui-angular/grids/core'; import { IgxNumberFilteringOperand } from 'igniteui-angular/core'; @@ -866,6 +866,61 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { }); }); + describe('Runtime summary settings in zoneless change detection', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + fix = TestBed.createComponent(IgxTreeGridSummariesKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetectionZoneless(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should be able to change summaryCalculationMode at runtime', async () => { + treeGrid.expandAll(); + await fix.whenStable(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + let rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + + treeGrid.summaryCalculationMode = 'rootLevelOnly'; + await wait(50); + await fix.whenStable(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + await wait(50); + await fix.whenStable(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + expect(summaryRow).toBeNull(); + + treeGrid.summaryCalculationMode = 'rootAndChildLevels'; + await wait(50); + await fix.whenStable(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + }); + }); + describe('CRUD with transactions', () => { let fix; let treeGrid; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts index eb2f64573e3..9a053743ecd 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts @@ -1,4 +1,5 @@ import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { provideZonelessChangeDetection } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxTreeGridComponent } from './tree-grid.component'; import { By } from '@angular/platform-browser'; @@ -221,18 +222,19 @@ describe('IgxTreeGrid Component Tests #tGrid', () => { // element.remove(); // }); - it('should auto-generate all columns', fakeAsync(() => { + it('should auto-generate all columns', async () => { grid.data = []; - tick(); + await fix.whenStable(); fix.detectChanges(); grid.data = SampleTestData.employeePrimaryForeignKeyTreeData(); - tick(); + await fix.whenStable(); fix.detectChanges(); grid.primaryKey = 'ID'; grid.foreignKey = 'ParentID'; - tick(); + await wait(100); + await fix.whenStable(); fix.detectChanges(); const expectedColumns = [...Object.keys(grid.data[0])]; @@ -240,7 +242,7 @@ describe('IgxTreeGrid Component Tests #tGrid', () => { expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); // Verify that records are also rendered by checking the first record cell expect(grid.getCellByColumn(0, 'ID').value).toEqual(1); - })); + }); it('should auto-generate columns without childDataKey', fakeAsync(() => { grid.data = []; @@ -296,6 +298,42 @@ describe('IgxTreeGrid Component Tests #tGrid', () => { })); }); + describe('Auto-generated columns in zoneless change detection', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()] + }); + fix = TestBed.createComponent(IgxTreeGridComponent); + grid = fix.componentInstance; + grid.autoGenerate = true; + + // When doing pure unit tests, the grid doesn't get removed after the test, because it overrides + // the element ID and the testbed cannot find it to remove it. + // The testbed is looking up components by [id^=root], so working around this by forcing root id + grid.id = SAFE_DISPOSE_COMP_ID; + fix.detectChanges(); + })); + + it('should auto-generate all columns', async () => { + grid.data = []; + await fix.whenStable(); + + grid.data = SampleTestData.employeePrimaryForeignKeyTreeData(); + await fix.whenStable(); + + grid.primaryKey = 'ID'; + grid.foreignKey = 'ParentID'; + await wait(100); + await fix.whenStable(); + + const expectedColumns = [...Object.keys(grid.data[0])]; + + expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); + // Verify that records are also rendered by checking the first record cell + expect(grid.getCellByColumn(0, 'ID').value).toEqual(1); + }); + }); + describe('Loading Template', () => { beforeEach(waitForAsync(() => { fix = TestBed.createComponent(IgxTreeGridDefaultLoadingComponent); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index c7c81b44849..0e059aeedbf 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -2,7 +2,8 @@ import { EventEmitter, NgZone, Injectable } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; import { GridType } from 'igniteui-angular/grids/core'; import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; -import { Subscription } from 'rxjs'; +import { firstValueFrom, Subscription } from 'rxjs'; +import { GridFunctions } from './grid-functions.spec'; /** * Global beforeEach and afterEach checks to ensure test fails on specific warnings @@ -32,6 +33,16 @@ export const setupGridScrollDetection = (fixture: ComponentFixture, grid: G gridsubscriptions.push(grid.selected.subscribe(() => grid.cdr.detectChanges())); }; +/** + * Intentional no-op counterpart of {@link setupGridScrollDetection} for zoneless tests. + * The zoned helper subscribes to grid events and forces `detectChanges()`; a zoneless + * consumer must not do that, so these tests rely on the grid's own change detection and + * await `whenStable()`/a settle helper instead. Kept so zoneless specs can mirror the + * zoned setup call site without special-casing it. + */ +export const setupGridScrollDetectionZoneless = (_fixture: ComponentFixture, _grid: GridType) => { +}; + export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { setupGridScrollDetection(fixture, hierarchicalGrid); @@ -46,11 +57,151 @@ export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, _hierarchicalGrid: IgxHierarchicalGridComponent) => { +}; + export const clearGridSubs = () => { gridsubscriptions.forEach(sub => sub.unsubscribe()); gridsubscriptions = []; } +const waitFor = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export interface GridScrollEventOptions { + waitMs?: number; + waitForChunkLoad?: boolean; +} + +export const dispatchGridVerticalScroll = async ( + fixture: ComponentFixture, + grid: GridType, + options: GridScrollEventOptions = {} +) => { + const { waitMs = 60, waitForChunkLoad = false } = options; + const chunkLoad = waitForChunkLoad ? firstValueFrom(grid.verticalScrollContainer.chunkLoad) : null; + grid.verticalScrollContainer.getScroll().dispatchEvent(new Event('scroll')); + await waitFor(waitMs); + fixture.detectChanges(); + await chunkLoad; +}; + +export const dispatchGridHorizontalScroll = async ( + fixture: ComponentFixture, + grid: GridType, + options: GridScrollEventOptions = {} +) => { + const { waitMs = 60, waitForChunkLoad = false } = options; + const chunkLoad = waitForChunkLoad ? firstValueFrom(grid.parentVirtDir.chunkLoad) : null; + grid.headerContainer.getScroll().dispatchEvent(new Event('scroll')); + await waitFor(waitMs); + fixture.detectChanges(); + await chunkLoad; +}; + +export const dispatchGridScrollEvents = async ( + fixture: ComponentFixture, + grid: GridType, + options: GridScrollEventOptions = {} +) => { + await dispatchGridVerticalScroll(fixture, grid, options); + await dispatchGridHorizontalScroll(fixture, grid, options); +}; + +/** + * Simulates a grid-content keyboard navigation that scrolls the grid, and resolves once + * the resulting `activeNodeChange` has fired. + * + * The `activeNodeChange` subscription is created before the keydown so a synchronous emit + * is not missed; the dispatched scroll events (plus their change detection) drive the + * deferred post-render work — the scrolled-in row/cell renders and the navigation + * continuation activates the target cell — without racing a fixed delay. + * + * @param axis which scroll direction(s) the navigation triggers; use `'vertical'` for + * up/down navigation and `'both'` (default) for Home/End/Ctrl combinations that can also + * scroll horizontally. + */ +export const navigateWithGridScroll = async ( + fixture: ComponentFixture, + grid: GridType, + key: string, + { ctrlKey = false, axis = 'both', waitMs = 60 }: { ctrlKey?: boolean; axis?: 'vertical' | 'both'; waitMs?: number } = {} +): Promise => { + const activeNodeChange = firstValueFrom(grid.activeNodeChange); + GridFunctions.simulateGridContentKeydown(fixture, key, false, false, ctrlKey); + if (axis === 'vertical') { + await dispatchGridVerticalScroll(fixture, grid, { waitMs }); + } else { + await dispatchGridScrollEvents(fixture, grid, { waitMs }); + } + await activeNodeChange; + fixture.detectChanges(); +}; + +/** + * Sets the grid's vertical scroll position and resolves once the resulting `chunkLoad` + * has fired, running change-detection passes while waiting. The `chunkLoad` subscription + * is created before the scroll to avoid missing a synchronous emit, and the interleaved + * renders let the (deferred) emit run without racing a fixed delay. + */ +export const setGridVerticalScrollTop = async ( + fixture: ComponentFixture, + grid: GridType, + scrollTop: number, + { maxAttempts = 30, intervalMs = 20 }: { maxAttempts?: number; intervalMs?: number } = {} +): Promise => { + const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); + let loaded = false; + void chunkLoad.then(() => { + loaded = true; + }); + grid.verticalScrollContainer.getScroll().scrollTop = scrollTop; + for (let attempt = 0; attempt < maxAttempts && !loaded; attempt++) { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + fixture.detectChanges(); + await fixture.whenStable(); + } + await chunkLoad; + fixture.detectChanges(); +}; + +/** + * Polls until `predicate` returns truthy or the attempt budget is exhausted, running a + * change-detection pass on every tick. Use it for interactions whose visible result + * settles over several asynchronous render passes — e.g. keyboard navigation that first + * scrolls a virtualized grid and only then activates/selects the target cell — instead + * of a single fixed `wait(...)` that races the render cascade. + */ +export const waitForGridSettle = async ( + fixture: ComponentFixture, + predicate: () => boolean, + { maxAttempts = 20, intervalMs = 50 }: { maxAttempts?: number; intervalMs?: number } = {} +): Promise => { + for (let attempt = 0; attempt < maxAttempts && !predicate(); attempt++) { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + fixture.detectChanges(); + await fixture.whenStable(); + } +}; + +/** + * Waits until the grid's vertical scroll position stops changing between animation + * frames. Variable-size virtualization corrects its size estimates over several + * render + scroll cycles, which zoneless tests must await instead of forcing + * change detection from grid events. + */ +export const settleGridScroll = async (fixture: ComponentFixture, grid: GridType, maxFrames = 20) => { + let stableFrames = 0; + let previousTop = Number.NaN; + while (maxFrames-- > 0 && stableFrames < 2) { + const currentTop = grid.verticalScrollContainer.getScroll().scrollTop; + stableFrames = currentTop === previousTop ? stableFrames + 1 : 0; + previousTop = currentTop; + await new Promise(resolve => setTimeout(resolve, 32)); + await fixture.whenStable(); + } +}; + /** * Sets element size as a inline style */ From 4f69aec8bcd8d0fd0e86209c4ee24b29b9a8e02d Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Sun, 5 Jul 2026 23:57:24 +0300 Subject: [PATCH 09/32] chore(grids): address code quality review findings --- .../igniteui-angular/core/src/core/utils.ts | 11 +++++++++-- .../grids/grid/src/column-group.spec.ts | 13 ++++++------- .../grids/grid/src/column.spec.ts | 18 +++++++++--------- .../grids/grid/src/grid.master-detail.spec.ts | 2 +- .../src/tree-grid-keyBoardNav.spec.ts | 2 -- .../test-utils/helper-utils.spec.ts | 3 +++ 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index f37864bbcd2..c9ff8650252 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -11,6 +11,13 @@ export const ELEMENTS_TOKEN = /*@__PURE__*/new InjectionToken('elements /** @hidden @internal */ export type RenderPhase = 'earlyRead' | 'write' | 'mixedReadWrite' | 'read'; +interface AfterNextRenderSpec { + earlyRead?: () => void; + write?: () => void; + mixedReadWrite?: () => void; + read?: () => void; +} + /** * Schedules `callback` to run once after Angular finishes the next render pass. * @@ -22,9 +29,9 @@ export type RenderPhase = 'earlyRead' | 'write' | 'mixedReadWrite' | 'read'; * @hidden @internal */ export function runAfterRenderOnce(injector: Injector, callback: () => void, phase: RenderPhase = 'mixedReadWrite'): AfterRenderRef { - const spec: Partial void>> = {}; + const spec: AfterNextRenderSpec = {}; spec[phase] = callback; - return afterNextRender(spec as { mixedReadWrite: () => void }, { injector }); + return afterNextRender(spec, { injector }); } /** diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts index 607f36b5e8e..c9cc89f945d 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -612,17 +612,16 @@ describe('IgxGrid - multi-column headers #grid', () => { await wait(16); fixture.detectChanges(); const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; - const availableWidth = (parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh).toString(); + const availableWidth = parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; const locationColGroup = getColGroup(grid, 'Location'); - const colWidth = parseInt(availableWidth, 10) / 3; - const colWidthPx = colWidth + 'px'; - expect(locationColGroup.width).toBe((colWidth * 3) + 'px'); + const colWidth = availableWidth / 3; + expect(parseFloat(locationColGroup.width)).toBeCloseTo(availableWidth, 5); const countryColumn = grid.getColumnByName('Country'); - expect(countryColumn.width).toBe(colWidthPx); + expect(parseFloat(countryColumn.width)).toBeCloseTo(colWidth, 5); const regionColumn = grid.getColumnByName('Region'); - expect(regionColumn.width).toBe(colWidthPx); + expect(parseFloat(regionColumn.width)).toBeCloseTo(colWidth, 5); const cityColumn = grid.getColumnByName('City'); - expect(cityColumn.width).toBe(colWidthPx); + expect(parseFloat(cityColumn.width)).toBeCloseTo(colWidth, 5); }); it('Width should be correct. Column group with three columns. Width in px.', () => { diff --git a/projects/igniteui-angular/grids/grid/src/column.spec.ts b/projects/igniteui-angular/grids/grid/src/column.spec.ts index c467659fc3e..9a734f50a61 100644 --- a/projects/igniteui-angular/grids/grid/src/column.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column.spec.ts @@ -1162,7 +1162,7 @@ describe('IgxGrid - Column properties #grid', () => { receiveTimeColumn.editorOptions = { dateTimeFormat: 'h-mm-ss aaaaa' }; fix.detectChanges(); - producedDateColumn._cells[0].setEditMode(true) + producedDateColumn._cells[0].setEditMode(true); fix.detectChanges(); tick(); @@ -1174,7 +1174,7 @@ describe('IgxGrid - Column properties #grid', () => { expect((dateTimeEditor.nativeElement as any).value).toEqual('2014-10-01'); - orderDateColumn._cells[0].setEditMode(true) + orderDateColumn._cells[0].setEditMode(true); fix.detectChanges(); tick(); @@ -1186,7 +1186,7 @@ describe('IgxGrid - Column properties #grid', () => { expect((dateTimeEditor.nativeElement as any).value).toEqual('2015--10--01'); - receiveTimeColumn._cells[0].setEditMode(true) + receiveTimeColumn._cells[0].setEditMode(true); fix.detectChanges(); tick(); @@ -1338,7 +1338,7 @@ describe('IgxGrid - Column properties #grid', () => { }; fix.detectChanges(); - producedDateColumn._cells[0].setEditMode(true) + producedDateColumn._cells[0].setEditMode(true); fix.detectChanges(); let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1349,7 +1349,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); - orderDateColumn._cells[0].setEditMode(true) + orderDateColumn._cells[0].setEditMode(true); fix.detectChanges(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1360,7 +1360,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); - receiveTimeColumn._cells[0].setEditMode(true) + receiveTimeColumn._cells[0].setEditMode(true); fix.detectChanges(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1447,7 +1447,7 @@ describe('IgxGrid - Column properties #grid', () => { }; await fix.whenStable(); - producedDateColumn._cells[0].setEditMode(true) + producedDateColumn._cells[0].setEditMode(true); await fix.whenStable(); let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1458,7 +1458,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); - orderDateColumn._cells[0].setEditMode(true) + orderDateColumn._cells[0].setEditMode(true); await fix.whenStable(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1469,7 +1469,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); - receiveTimeColumn._cells[0].setEditMode(true) + receiveTimeColumn._cells[0].setEditMode(true); await fix.whenStable(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index 8c0bfc59911..c198e798249 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -10,7 +10,7 @@ import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; import { IgxGridExpandableCellComponent } from './expandable-cell.component'; import { GridSummaryPosition, GridSelectionMode, CellType, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; -import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection, setupGridScrollDetectionZoneless, settleGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; import { GridSummaryCalculationMode, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 12ea2db0479..095c666b628 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -1178,7 +1178,6 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { await wait(DEBOUNCETIME); await fix.whenStable(); - cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(4); }); @@ -1264,7 +1263,6 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); TreeGridFunctions.moveCellUpDown(fix, treeGrid, 9, 'ID', false); - cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); await wait(DEBOUNCETIME); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index 0e059aeedbf..4c9ba4c0749 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -182,6 +182,9 @@ export const waitForGridSettle = async ( fixture.detectChanges(); await fixture.whenStable(); } + if (!predicate()) { + throw new Error('waitForGridSettle: condition was not met within the allotted attempts.'); + } }; /** From de1c4bc1a5f371ff791cbff72a202cdca7231f86 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 6 Jul 2026 00:19:49 +0300 Subject: [PATCH 10/32] chore(grids): address code quality review findings --- .../src/app/custom-strategy.spec.ts | 12 +- .../igniteui-angular/core/src/core/utils.ts | 2 +- .../grids/grid/src/column-group.spec.ts | 10 +- .../grid/src/grid-mrl-keyboard-nav.spec.ts | 4 +- .../grids/grid/src/grid.search.spec.ts | 6 +- .../src/select/select-positioning-strategy.ts | 231 ++++++++++++++++++ .../test-utils/helper-utils.spec.ts | 3 + 7 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 projects/igniteui-angular/select/src/select/select-positioning-strategy.ts diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index a761729e2fd..4dcfb3fbcbb 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -19,6 +19,8 @@ import { defineComponents } from '../utils/register'; describe('Elements: ', () => { let testContainer: HTMLDivElement; + const waitForChildrenResolved = (element: IgcNgElement) => + firstValueFrom(fromEvent(element, 'childrenResolved')); beforeAll(async () =>{ defineComponents( @@ -205,19 +207,18 @@ describe('Elements: ', () => { `; testContainer.innerHTML = innerHtml; - // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); - const grid = document.querySelector>('#testGrid'); const thirdGroup = document.querySelector('igc-column-layout[header="Product Stock"]'); const secondGroup = document.querySelector('igc-column-layout[header="Product Details"]'); + await waitForChildrenResolved(grid); expect(grid.columns.length).toEqual(8); expect(grid.getColumnByName('ProductID')).toBeTruthy(); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); + const columnsRemoved = waitForChildrenResolved(grid); grid.removeChild(secondGroup); - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + await columnsRemoved; expect(grid.columns.length).toEqual(4); expect(grid.getColumnByName('ProductID')).toBeTruthy(); @@ -228,8 +229,9 @@ describe('Elements: ', () => { const newColumn = document.createElement('igc-column'); newColumn.setAttribute('field', 'ProductName'); newGroup.appendChild(newColumn); + const columnsInserted = waitForChildrenResolved(grid); grid.insertBefore(newGroup, thirdGroup); - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + await columnsInserted; expect(grid.columns.length).toEqual(6); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index c9ff8650252..da17ca60e62 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -30,7 +30,7 @@ interface AfterNextRenderSpec { */ export function runAfterRenderOnce(injector: Injector, callback: () => void, phase: RenderPhase = 'mixedReadWrite'): AfterRenderRef { const spec: AfterNextRenderSpec = {}; - spec[phase] = callback; + spec[phase as keyof AfterNextRenderSpec] = callback; return afterNextRender(spec, { injector }); } diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts index c9cc89f945d..dd925602af9 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -615,13 +615,15 @@ describe('IgxGrid - multi-column headers #grid', () => { const availableWidth = parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; const locationColGroup = getColGroup(grid, 'Location'); const colWidth = availableWidth / 3; - expect(parseFloat(locationColGroup.width)).toBeCloseTo(availableWidth, 5); + const expectWidthWithinPixel = (actualWidth: string, expectedWidth: number) => + expect(Math.abs(parseFloat(actualWidth) - expectedWidth)).toBeLessThanOrEqual(1); + expectWidthWithinPixel(locationColGroup.width, availableWidth); const countryColumn = grid.getColumnByName('Country'); - expect(parseFloat(countryColumn.width)).toBeCloseTo(colWidth, 5); + expectWidthWithinPixel(countryColumn.width, colWidth); const regionColumn = grid.getColumnByName('Region'); - expect(parseFloat(regionColumn.width)).toBeCloseTo(colWidth, 5); + expectWidthWithinPixel(regionColumn.width, colWidth); const cityColumn = grid.getColumnByName('City'); - expect(parseFloat(cityColumn.width)).toBeCloseTo(colWidth, 5); + expectWidthWithinPixel(cityColumn.width, colWidth); }); it('Width should be correct. Column group with three columns. Width in px.', () => { diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index c6bb2a1157a..e3e5fef6ce2 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -1975,7 +1975,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - await navigateWithGridScroll(fix, grid, 'ArrowRight', { axis: 'vertical' }); + await navigateWithGridScroll(fix, grid, 'ArrowRight'); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Address'); @@ -1992,7 +1992,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow left - await navigateWithGridScroll(fix, grid, 'ArrowLeft', { axis: 'vertical' }); + await navigateWithGridScroll(fix, grid, 'ArrowLeft'); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); diff --git a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts index 8dca2d8f7e8..a8d222ea42f 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts @@ -1085,7 +1085,7 @@ describe('IgxGrid - search API #grid', () => { strategy: DefaultSortingStrategy.instance() }); await fix.whenStable(); - fix.detectChanges(); + fix.detectChanges(); grid.findNext('a'); await wait(); @@ -1093,10 +1093,10 @@ describe('IgxGrid - search API #grid', () => { const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); (grid as any).scrollTo(9, 0); - fix.detectChanges(); + fix.detectChanges(); await wait(); await fix.whenStable(); - fix.detectChanges(); + fix.detectChanges(); await chunkLoad; const row = grid.gridAPI.get_row_by_index(9); diff --git a/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts b/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts new file mode 100644 index 00000000000..8faa5e08f88 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts @@ -0,0 +1,231 @@ +import { VerticalAlignment, HorizontalAlignment, PositionSettings, ConnectedFit, Point, Size, BaseFitPositionStrategy, Util } from 'igniteui-angular/core'; +import { IPositionStrategy } from 'igniteui-angular/core'; + +import { IgxSelectBase } from './select.common'; +import { PlatformUtil } from 'igniteui-angular/core'; +import { Optional } from '@angular/core'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +/** @hidden @internal */ +export class SelectPositioningStrategy extends BaseFitPositionStrategy implements IPositionStrategy { + private _selectDefaultSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top, + openAnimation: fadeIn, + closeAnimation: fadeOut + }; + + // Global variables required for cases of !initialCall (page scroll/overlay repositionAll) + private global_yOffset = 0; + private global_xOffset = 0; + private global_styles: SelectStyles = {}; + + constructor(public select: IgxSelectBase, settings?: PositionSettings, @Optional() protected platform?: PlatformUtil) { + super(); + this.settings = Object.assign({}, this._selectDefaultSettings, settings); + } + + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + public override position(contentElement: HTMLElement, + size: Size, + document?: Document, + initialCall?: boolean, + target?: Point | HTMLElement): void { + const targetElement = target; + const rects = super.calculateElementRectangles(contentElement, targetElement); + // selectFit obj, to be used for both cases of initialCall and !initialCall(page scroll/overlay repositionAll) + const selectFit: SelectFit = { + verticalOffset: this.global_yOffset, + horizontalOffset: this.global_xOffset, + targetRect: rects.targetRect, + contentElementRect: rects.elementRect, + styles: this.global_styles, + scrollContainer: this.select.scrollContainer, + scrollContainerRect: this.select.scrollContainer.getBoundingClientRect() + }; + + if (initialCall) { + this.select.scrollContainer.scrollTop = 0; + // Fill in the required selectFit object properties. + selectFit.viewPortRect = Util.getViewportRect(document); + selectFit.itemElement = this.getInteractionItemElement(); + selectFit.itemRect = selectFit.itemElement.getBoundingClientRect(); + + // Calculate input and selected item elements style related variables + selectFit.styles = this.calculateStyles(selectFit, targetElement); + + selectFit.scrollAmount = this.calculateScrollAmount(selectFit); + // Calculate how much to offset the overlay container. + this.calculateYoffset(selectFit); + this.calculateXoffset(selectFit); + + super.updateViewPortFit(selectFit); + // container does not fit in viewPort and is out on Top or Bottom + if (selectFit.fitVertical.back < 0 || selectFit.fitVertical.forward < 0) { + this.fitInViewport(contentElement, selectFit); + } + // Calculate scrollTop independently of the dropdown, as we cover all `igsSelect` specific positioning and + // scrolling to selected item scenarios here. + this.select.scrollContainer.scrollTop = selectFit.scrollAmount; + } + this.setStyles(contentElement, selectFit); + } + + /** + * Obtain the selected item if there is such one or otherwise use the first one + */ + public getInteractionItemElement(): HTMLElement { + let itemElement; + if (this.select.selectedItem) { + itemElement = this.select.selectedItem.element.nativeElement; + } else { + itemElement = this.select.getFirstItemElement(); + } + return itemElement; + } + + /** + * Position the items outer container so selected item text is positioned over input text and if header + * And/OR footer - both header/footer are visible + * + * @param selectFit selectFit to use for computation. + */ + protected fitInViewport(contentElement: HTMLElement, selectFit: SelectFit) { + const footer = selectFit.scrollContainerRect.bottom - selectFit.contentElementRect.bottom; + const header = selectFit.scrollContainerRect.top - selectFit.contentElementRect.top; + const lastItemFitSize = selectFit.targetRect.bottom + selectFit.styles.itemTextToInputTextDiff - footer; + const firstItemFitSize = selectFit.targetRect.top - selectFit.styles.itemTextToInputTextDiff - header; + // out of viewPort on Top + if (selectFit.fitVertical.back < 0) { + const possibleScrollAmount = selectFit.scrollContainer.scrollHeight - + selectFit.scrollContainerRect.height - selectFit.scrollAmount; + if (possibleScrollAmount + selectFit.fitVertical.back > 0 && firstItemFitSize > selectFit.viewPortRect.top) { + selectFit.scrollAmount -= selectFit.fitVertical.back; + selectFit.verticalOffset -= selectFit.fitVertical.back; + this.global_yOffset = selectFit.verticalOffset; + } else { + selectFit.verticalOffset = 0 ; + this.global_yOffset = 0; + } + // out of viewPort on Bottom + } else if (selectFit.fitVertical.forward < 0) { + if (selectFit.scrollAmount + selectFit.fitVertical.forward > 0 && lastItemFitSize < selectFit.viewPortRect.bottom) { + selectFit.scrollAmount += selectFit.fitVertical.forward; + selectFit.verticalOffset += selectFit.fitVertical.forward; + this.global_yOffset = selectFit.verticalOffset; + } else { + selectFit.verticalOffset = -selectFit.contentElementRect.height + selectFit.targetRect.height; + this.global_yOffset = selectFit.verticalOffset; + } + } + } + + /** + * Sets element's style which effectively positions the provided element + * + * @param element Element to position + * @param selectFit selectFit to use for computation. + * @param initialCall should be true if this is the initial call to the position method calling setStyles + */ + protected setStyles(contentElement: HTMLElement, selectFit: SelectFit) { + super.setStyle(contentElement, selectFit.targetRect, selectFit.contentElementRect, selectFit); + contentElement.style.width = `${selectFit.styles.contentElementNewWidth}px`; // manage container based on paddings? + this.global_styles.contentElementNewWidth = selectFit.styles.contentElementNewWidth; + } + + /** + * Calculate selected item scroll position. + */ + private calculateScrollAmount(selectFit: SelectFit): number { + const itemElementRect = selectFit.itemRect; + const scrollContainer = selectFit.scrollContainer; + const scrollContainerRect = selectFit.scrollContainerRect; + const scrollDelta = scrollContainerRect.top - itemElementRect.top; + let scrollPosition = scrollContainer.scrollTop - scrollDelta; + + const dropDownHeight = scrollContainer.clientHeight; + scrollPosition -= dropDownHeight / 2; + scrollPosition += itemElementRect.height / 2; + + return Math.round(Math.min(Math.max(0, scrollPosition), scrollContainer.scrollHeight - scrollContainerRect.height)); + } + + /** + * Calculate the necessary input and selected item styles to be used for positioning item text over input text. + * Calculate & Set default items container width. + * + * @param selectFit selectFit to use for computation. + */ + private calculateStyles(selectFit: SelectFit, target: Point | HTMLElement): SelectStyles { + const styles: SelectStyles = {}; + const inputElementStyles = window.getComputedStyle(target as Element); + const itemElementStyles = window.getComputedStyle(selectFit.itemElement); + const numericInputFontSize = parseFloat(inputElementStyles.fontSize); + const numericInputPaddingTop = parseFloat(inputElementStyles.paddingTop); + const numericInputPaddingBottom = parseFloat(inputElementStyles.paddingBottom); + const numericItemFontSize = parseFloat(itemElementStyles.fontSize); + const inputTextToInputTop = ((selectFit.targetRect.bottom - numericInputPaddingBottom) + - (selectFit.targetRect.top + numericInputPaddingTop) - numericInputFontSize) / 2; + const itemTextToItemTop = (selectFit.itemRect.height - numericItemFontSize) / 2; + styles.itemTextToInputTextDiff = Math.round(itemTextToItemTop - inputTextToInputTop - numericInputPaddingTop); + + const numericLeftPadding = parseFloat(itemElementStyles.paddingLeft); + const numericTextIndent = parseFloat(itemElementStyles.textIndent); + + styles.itemTextPadding = numericLeftPadding; + styles.itemTextIndent = numericTextIndent; + // 24 is the input's toggle ddl icon width + styles.contentElementNewWidth = selectFit.targetRect.width + 24 + numericLeftPadding * 2; + + return styles; + } + + /** + * Calculate how much to offset the overlay container for Y-axis. + */ + private calculateYoffset(selectFit: SelectFit) { + selectFit.verticalOffset = -(selectFit.itemRect.top - selectFit.contentElementRect.top + + selectFit.styles.itemTextToInputTextDiff - selectFit.scrollAmount); + this.global_yOffset = selectFit.verticalOffset; + } + + /** + * Calculate how much to offset the overlay container for X-axis. + */ + private calculateXoffset(selectFit: SelectFit) { + selectFit.horizontalOffset = selectFit.styles.itemTextIndent - selectFit.styles.itemTextPadding; + this.global_xOffset = selectFit.horizontalOffset; + } +} + +/** @hidden */ +export interface SelectFit extends ConnectedFit { + itemElement?: HTMLElement; + scrollContainer: HTMLElement; + scrollContainerRect: ClientRect; + itemRect?: ClientRect; + styles?: SelectStyles; + scrollAmount?: number; +} + +/** @hidden */ +export interface SelectStyles { + itemTextPadding?: number; + itemTextIndent?: number; + itemTextToInputTextDiff?: number; + contentElementNewWidth?: number; + numericLeftPadding?: number; +} diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index 4c9ba4c0749..63ccbb168c8 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -161,6 +161,9 @@ export const setGridVerticalScrollTop = async ( fixture.detectChanges(); await fixture.whenStable(); } + if (!loaded) { + throw new Error(`setGridVerticalScrollTop: chunkLoad did not fire within ${maxAttempts} attempts.`); + } await chunkLoad; fixture.detectChanges(); }; From e0f3f17519e6b71e917bd47db74eb55c93c4a25c Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 6 Jul 2026 00:25:47 +0300 Subject: [PATCH 11/32] chore(*): remove unused file --- .../src/select/select-positioning-strategy.ts | 231 ------------------ 1 file changed, 231 deletions(-) delete mode 100644 projects/igniteui-angular/select/src/select/select-positioning-strategy.ts diff --git a/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts b/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts deleted file mode 100644 index 8faa5e08f88..00000000000 --- a/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { VerticalAlignment, HorizontalAlignment, PositionSettings, ConnectedFit, Point, Size, BaseFitPositionStrategy, Util } from 'igniteui-angular/core'; -import { IPositionStrategy } from 'igniteui-angular/core'; - -import { IgxSelectBase } from './select.common'; -import { PlatformUtil } from 'igniteui-angular/core'; -import { Optional } from '@angular/core'; -import { fadeIn, fadeOut } from 'igniteui-angular/animations'; - -/** @hidden @internal */ -export class SelectPositioningStrategy extends BaseFitPositionStrategy implements IPositionStrategy { - private _selectDefaultSettings = { - horizontalDirection: HorizontalAlignment.Right, - verticalDirection: VerticalAlignment.Bottom, - horizontalStartPoint: HorizontalAlignment.Left, - verticalStartPoint: VerticalAlignment.Top, - openAnimation: fadeIn, - closeAnimation: fadeOut - }; - - // Global variables required for cases of !initialCall (page scroll/overlay repositionAll) - private global_yOffset = 0; - private global_xOffset = 0; - private global_styles: SelectStyles = {}; - - constructor(public select: IgxSelectBase, settings?: PositionSettings, @Optional() protected platform?: PlatformUtil) { - super(); - this.settings = Object.assign({}, this._selectDefaultSettings, settings); - } - - /** - * Position the element based on the PositionStrategy implementing this interface. - * - * @param contentElement The HTML element to be positioned - * @param size Size of the element - * @param document reference to the Document object - * @param initialCall should be true if this is the initial call to the method - * @param target attaching target for the component to show - * ```typescript - * settings.positionStrategy.position(content, size, document, true); - * ``` - */ - public override position(contentElement: HTMLElement, - size: Size, - document?: Document, - initialCall?: boolean, - target?: Point | HTMLElement): void { - const targetElement = target; - const rects = super.calculateElementRectangles(contentElement, targetElement); - // selectFit obj, to be used for both cases of initialCall and !initialCall(page scroll/overlay repositionAll) - const selectFit: SelectFit = { - verticalOffset: this.global_yOffset, - horizontalOffset: this.global_xOffset, - targetRect: rects.targetRect, - contentElementRect: rects.elementRect, - styles: this.global_styles, - scrollContainer: this.select.scrollContainer, - scrollContainerRect: this.select.scrollContainer.getBoundingClientRect() - }; - - if (initialCall) { - this.select.scrollContainer.scrollTop = 0; - // Fill in the required selectFit object properties. - selectFit.viewPortRect = Util.getViewportRect(document); - selectFit.itemElement = this.getInteractionItemElement(); - selectFit.itemRect = selectFit.itemElement.getBoundingClientRect(); - - // Calculate input and selected item elements style related variables - selectFit.styles = this.calculateStyles(selectFit, targetElement); - - selectFit.scrollAmount = this.calculateScrollAmount(selectFit); - // Calculate how much to offset the overlay container. - this.calculateYoffset(selectFit); - this.calculateXoffset(selectFit); - - super.updateViewPortFit(selectFit); - // container does not fit in viewPort and is out on Top or Bottom - if (selectFit.fitVertical.back < 0 || selectFit.fitVertical.forward < 0) { - this.fitInViewport(contentElement, selectFit); - } - // Calculate scrollTop independently of the dropdown, as we cover all `igsSelect` specific positioning and - // scrolling to selected item scenarios here. - this.select.scrollContainer.scrollTop = selectFit.scrollAmount; - } - this.setStyles(contentElement, selectFit); - } - - /** - * Obtain the selected item if there is such one or otherwise use the first one - */ - public getInteractionItemElement(): HTMLElement { - let itemElement; - if (this.select.selectedItem) { - itemElement = this.select.selectedItem.element.nativeElement; - } else { - itemElement = this.select.getFirstItemElement(); - } - return itemElement; - } - - /** - * Position the items outer container so selected item text is positioned over input text and if header - * And/OR footer - both header/footer are visible - * - * @param selectFit selectFit to use for computation. - */ - protected fitInViewport(contentElement: HTMLElement, selectFit: SelectFit) { - const footer = selectFit.scrollContainerRect.bottom - selectFit.contentElementRect.bottom; - const header = selectFit.scrollContainerRect.top - selectFit.contentElementRect.top; - const lastItemFitSize = selectFit.targetRect.bottom + selectFit.styles.itemTextToInputTextDiff - footer; - const firstItemFitSize = selectFit.targetRect.top - selectFit.styles.itemTextToInputTextDiff - header; - // out of viewPort on Top - if (selectFit.fitVertical.back < 0) { - const possibleScrollAmount = selectFit.scrollContainer.scrollHeight - - selectFit.scrollContainerRect.height - selectFit.scrollAmount; - if (possibleScrollAmount + selectFit.fitVertical.back > 0 && firstItemFitSize > selectFit.viewPortRect.top) { - selectFit.scrollAmount -= selectFit.fitVertical.back; - selectFit.verticalOffset -= selectFit.fitVertical.back; - this.global_yOffset = selectFit.verticalOffset; - } else { - selectFit.verticalOffset = 0 ; - this.global_yOffset = 0; - } - // out of viewPort on Bottom - } else if (selectFit.fitVertical.forward < 0) { - if (selectFit.scrollAmount + selectFit.fitVertical.forward > 0 && lastItemFitSize < selectFit.viewPortRect.bottom) { - selectFit.scrollAmount += selectFit.fitVertical.forward; - selectFit.verticalOffset += selectFit.fitVertical.forward; - this.global_yOffset = selectFit.verticalOffset; - } else { - selectFit.verticalOffset = -selectFit.contentElementRect.height + selectFit.targetRect.height; - this.global_yOffset = selectFit.verticalOffset; - } - } - } - - /** - * Sets element's style which effectively positions the provided element - * - * @param element Element to position - * @param selectFit selectFit to use for computation. - * @param initialCall should be true if this is the initial call to the position method calling setStyles - */ - protected setStyles(contentElement: HTMLElement, selectFit: SelectFit) { - super.setStyle(contentElement, selectFit.targetRect, selectFit.contentElementRect, selectFit); - contentElement.style.width = `${selectFit.styles.contentElementNewWidth}px`; // manage container based on paddings? - this.global_styles.contentElementNewWidth = selectFit.styles.contentElementNewWidth; - } - - /** - * Calculate selected item scroll position. - */ - private calculateScrollAmount(selectFit: SelectFit): number { - const itemElementRect = selectFit.itemRect; - const scrollContainer = selectFit.scrollContainer; - const scrollContainerRect = selectFit.scrollContainerRect; - const scrollDelta = scrollContainerRect.top - itemElementRect.top; - let scrollPosition = scrollContainer.scrollTop - scrollDelta; - - const dropDownHeight = scrollContainer.clientHeight; - scrollPosition -= dropDownHeight / 2; - scrollPosition += itemElementRect.height / 2; - - return Math.round(Math.min(Math.max(0, scrollPosition), scrollContainer.scrollHeight - scrollContainerRect.height)); - } - - /** - * Calculate the necessary input and selected item styles to be used for positioning item text over input text. - * Calculate & Set default items container width. - * - * @param selectFit selectFit to use for computation. - */ - private calculateStyles(selectFit: SelectFit, target: Point | HTMLElement): SelectStyles { - const styles: SelectStyles = {}; - const inputElementStyles = window.getComputedStyle(target as Element); - const itemElementStyles = window.getComputedStyle(selectFit.itemElement); - const numericInputFontSize = parseFloat(inputElementStyles.fontSize); - const numericInputPaddingTop = parseFloat(inputElementStyles.paddingTop); - const numericInputPaddingBottom = parseFloat(inputElementStyles.paddingBottom); - const numericItemFontSize = parseFloat(itemElementStyles.fontSize); - const inputTextToInputTop = ((selectFit.targetRect.bottom - numericInputPaddingBottom) - - (selectFit.targetRect.top + numericInputPaddingTop) - numericInputFontSize) / 2; - const itemTextToItemTop = (selectFit.itemRect.height - numericItemFontSize) / 2; - styles.itemTextToInputTextDiff = Math.round(itemTextToItemTop - inputTextToInputTop - numericInputPaddingTop); - - const numericLeftPadding = parseFloat(itemElementStyles.paddingLeft); - const numericTextIndent = parseFloat(itemElementStyles.textIndent); - - styles.itemTextPadding = numericLeftPadding; - styles.itemTextIndent = numericTextIndent; - // 24 is the input's toggle ddl icon width - styles.contentElementNewWidth = selectFit.targetRect.width + 24 + numericLeftPadding * 2; - - return styles; - } - - /** - * Calculate how much to offset the overlay container for Y-axis. - */ - private calculateYoffset(selectFit: SelectFit) { - selectFit.verticalOffset = -(selectFit.itemRect.top - selectFit.contentElementRect.top + - selectFit.styles.itemTextToInputTextDiff - selectFit.scrollAmount); - this.global_yOffset = selectFit.verticalOffset; - } - - /** - * Calculate how much to offset the overlay container for X-axis. - */ - private calculateXoffset(selectFit: SelectFit) { - selectFit.horizontalOffset = selectFit.styles.itemTextIndent - selectFit.styles.itemTextPadding; - this.global_xOffset = selectFit.horizontalOffset; - } -} - -/** @hidden */ -export interface SelectFit extends ConnectedFit { - itemElement?: HTMLElement; - scrollContainer: HTMLElement; - scrollContainerRect: ClientRect; - itemRect?: ClientRect; - styles?: SelectStyles; - scrollAmount?: number; -} - -/** @hidden */ -export interface SelectStyles { - itemTextPadding?: number; - itemTextIndent?: number; - itemTextToInputTextDiff?: number; - contentElementNewWidth?: number; - numericLeftPadding?: number; -} From 3d5b8d0f94f8fd45a174e1dd60538ee7df682ce6 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 6 Jul 2026 01:36:38 +0300 Subject: [PATCH 12/32] fix(grid): refactor navigation callback handling for improved clarity --- .../grids/grid/src/grid-base.directive.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 2723c519ee3..d17929ab7b8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -7817,8 +7817,7 @@ export abstract class IgxGridBaseDirective implements GridType, this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex); - const cbArgs = this.getNavigationArguments(row, visibleColIndex); - cb(cbArgs); + this.executeNavigationCallback(row, visibleColIndex, cb); }); } const dataViewIndex = this._getDataViewIndex(rowIndex); @@ -7829,7 +7828,17 @@ export abstract class IgxGridBaseDirective implements GridType, return; } + this.executeNavigationCallback(row, visibleColIndex, cb); + } + + private executeNavigationCallback(row, visibleColIndex, cb: (args: any) => void) { + if (!row) { + return; + } const args = this.getNavigationArguments(row, visibleColIndex); + if (!args.target) { + return; + } cb(args); } From 9394247b5c2fb1c748bc6ebd53049a343634d9fc Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 6 Jul 2026 02:01:29 +0300 Subject: [PATCH 13/32] chore(grids): address code quality review findings --- .../grids/grid/src/column-group.spec.ts | 15 ++--- .../src/hierarchical-grid.navigation.spec.ts | 60 ++++++++++++------- .../test-utils/helper-utils.spec.ts | 46 ++++++++++---- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts index dd925602af9..647550fd7f3 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -1781,17 +1781,18 @@ describe('IgxGrid - multi-column headers #grid', () => { grid = fixture.componentInstance.grid; const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; - const availableWidth = (parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh).toString(); + const availableWidth = parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; const locationColGroup = getColGroup(grid, 'Location'); - const colWidth = parseInt(availableWidth, 10) / 3; - const colWidthPx = colWidth + 'px'; - expect(locationColGroup.width).toBe((colWidth * 3) + 'px'); + const colWidth = availableWidth / 3; + const expectWidthWithinPixel = (actualWidth: string, expectedWidth: number) => + expect(Math.abs(parseFloat(actualWidth) - expectedWidth)).toBeLessThanOrEqual(1); + expectWidthWithinPixel(locationColGroup.width, availableWidth); const countryColumn = grid.getColumnByName('Country'); - expect(countryColumn.width).toBe(colWidthPx); + expectWidthWithinPixel(countryColumn.width, colWidth); const regionColumn = grid.getColumnByName('Region'); - expect(regionColumn.width).toBe(colWidthPx); + expectWidthWithinPixel(regionColumn.width, colWidth); const cityColumn = grid.getColumnByName('City'); - expect(cityColumn.width).toBe(colWidthPx); + expectWidthWithinPixel(cityColumn.width, colWidth); }); }); }); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index b0372813124..c1e0430293c 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -6,7 +6,7 @@ import { wait, UIInteractions, waitForSelectionChange } from '../../../test-util import { IgxRowIslandComponent } from './row-island.component'; import { By } from '@angular/platform-browser'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; -import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection, setupHierarchicalGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection, setupHierarchicalGridScrollDetectionZoneless, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; import { IPathSegment } from 'igniteui-angular/core'; @@ -30,6 +30,13 @@ const waitForInitializedGrid = (rowIsland: IgxRowIslandComponent, parentID: any) const getRowIslandByKey = (rowIslands, key: string) => rowIslands.toArray().find((rowIsland: IgxRowIslandComponent) => rowIsland.key === key); +const expectElementInView = (element: HTMLElement, parent: HTMLElement) => { + const elementRect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + expect(elementRect.bottom).toBeGreaterThanOrEqual(parentRect.top - 1); + expect(elementRect.top).toBeLessThanOrEqual(parentRect.bottom + 1); +}; + describe('IgxHierarchicalGrid Navigation', () => { let fixture; let hierarchicalGrid: IgxHierarchicalGridComponent; @@ -989,15 +996,12 @@ describe('IgxHierarchicalGrid Navigation', () => { }; await wait(16); hierarchicalGrid.navigation.navigateToChildGrid([path]); - await wait(DEBOUNCE_TIME); + await settleGridScrollEvents(fixture, hierarchicalGrid); fixture.detectChanges(); const childGrid = hierarchicalGrid.gridAPI.getChildGrid([path]).nativeElement; expect(childGrid).not.toBe(undefined); - const parentBottom = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().bottom; - const parentTop = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().top; - // check it's in view within its parent - expect(childGrid.getBoundingClientRect().bottom <= parentBottom && childGrid.getBoundingClientRect().top >= parentTop); + expectElementInView(childGrid, hierarchicalGrid.tbody.nativeElement); }); it('should navigate to exact nested child grid with navigateToChildGrid.', async() => { hierarchicalGrid.expandChildren = false; @@ -1023,16 +1027,18 @@ describe('IgxHierarchicalGrid Navigation', () => { const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); await settleGridScrollEvents(fixture, hierarchicalGrid); - const childGrid = (await childGridInitialized).grid.nativeElement; + await childGridInitialized; + const childGridComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }]) as IgxHierarchicalGridComponent; + const childGrid = childGridComponent.nativeElement; expect(childGrid).not.toBe(undefined); await settleGridScrollEvents(fixture, hierarchicalGrid); - const childGridNested = (await nestedChildGridInitialized).grid.nativeElement; + await nestedChildGridInitialized; + const childGridNestedComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }, { ...targetNested }]) as IgxHierarchicalGridComponent; + const childGridNested = childGridNestedComponent.nativeElement; expect(childGridNested).not.toBe(undefined); + await settleGridScrollEvents(fixture, childGridComponent, hierarchicalGrid); - const parentBottom = childGrid.getBoundingClientRect().bottom; - const parentTop = childGrid.getBoundingClientRect().top; - // check it's in view within its parent - expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); + expectElementInView(childGridNested, childGrid); }); }); @@ -1457,6 +1463,7 @@ describe('IgxHierarchicalGrid Navigation', () => { await wait(DEBOUNCE_TIME); hierarchicalGrid.primaryKey = 'ID'; hierarchicalGrid.childLayoutList.toArray()[0].primaryKey = 'ID'; + fixture.detectChanges(); await wait(DEBOUNCE_TIME); await fixture.whenStable(); const targetRoot: IPathSegment = { @@ -1474,18 +1481,29 @@ describe('IgxHierarchicalGrid Navigation', () => { const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); - hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); - await settleGridScrollEvents(fixture, hierarchicalGrid); - const childGrid = (await childGridInitialized).grid.nativeElement; + const navigationDone = new Promise(resolve => { + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], resolve); + }); + await navigationDone; + await fixture.whenStable(); + const initializedChildGrid = (await childGridInitialized).grid; + const childGridComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }]) as IgxHierarchicalGridComponent; + const childGrid = childGridComponent.nativeElement; expect(childGrid).not.toBe(undefined); - await settleGridScrollEvents(fixture, hierarchicalGrid); - const childGridNested = (await nestedChildGridInitialized).grid.nativeElement; + const initializedNestedChildGrid = (await nestedChildGridInitialized).grid; + const childGridNestedComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }, { ...targetNested }]) as IgxHierarchicalGridComponent; + const childGridNested = childGridNestedComponent.nativeElement; expect(childGridNested).not.toBe(undefined); + expect(initializedChildGrid).toBe(childGridComponent); + expect(initializedNestedChildGrid).toBe(childGridNestedComponent); + await waitForGridSettle(fixture, () => { + const elementRect = childGridNested.getBoundingClientRect(); + const parentRect = childGrid.getBoundingClientRect(); + return elementRect.bottom >= parentRect.top - 1 && + elementRect.top <= parentRect.bottom + 1; + }); - const parentBottom = childGrid.getBoundingClientRect().bottom; - const parentTop = childGrid.getBoundingClientRect().top; - // check it's in view within its parent - expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); + expectElementInView(childGridNested, childGrid); }); }); }); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index 63ccbb168c8..e753c1b005c 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -33,14 +33,14 @@ export const setupGridScrollDetection = (fixture: ComponentFixture, grid: G gridsubscriptions.push(grid.selected.subscribe(() => grid.cdr.detectChanges())); }; -/** - * Intentional no-op counterpart of {@link setupGridScrollDetection} for zoneless tests. - * The zoned helper subscribes to grid events and forces `detectChanges()`; a zoneless - * consumer must not do that, so these tests rely on the grid's own change detection and - * await `whenStable()`/a settle helper instead. Kept so zoneless specs can mirror the - * zoned setup call site without special-casing it. - */ -export const setupGridScrollDetectionZoneless = (_fixture: ComponentFixture, _grid: GridType) => { +export const setupGridScrollDetectionZoneless = (fixture: ComponentFixture, grid: GridType) => { + const isFixtureDestroyed = () => fixture.componentRef.hostView.destroyed; + const scheduleFixtureDetectChanges = scheduleDetectChanges(() => fixture.detectChanges(), isFixtureDestroyed); + const scheduleGridDetectChanges = scheduleDetectChanges(() => grid.cdr.detectChanges(), isFixtureDestroyed); + gridsubscriptions.push(grid.verticalScrollContainer.chunkLoad.subscribe(scheduleFixtureDetectChanges)); + gridsubscriptions.push(grid.parentVirtDir.chunkLoad.subscribe(scheduleFixtureDetectChanges)); + gridsubscriptions.push(grid.activeNodeChange.subscribe(scheduleGridDetectChanges)); + gridsubscriptions.push(grid.selected.subscribe(scheduleGridDetectChanges)); }; export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { @@ -57,8 +57,18 @@ export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, _hierarchicalGrid: IgxHierarchicalGridComponent) => { +export const setupHierarchicalGridScrollDetectionZoneless = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { + setupGridScrollDetectionZoneless(fixture, hierarchicalGrid); + + const existingChildren = hierarchicalGrid.gridAPI.getChildGrids(true); + existingChildren.forEach(child => setupGridScrollDetectionZoneless(fixture, child)); + + const layouts = hierarchicalGrid.allLayoutList.toArray(); + layouts.forEach((layout) => { + gridsubscriptions.push(layout.gridCreated.subscribe(evt => { + setupGridScrollDetectionZoneless(fixture, evt.grid); + })); + }); }; export const clearGridSubs = () => { @@ -66,6 +76,22 @@ export const clearGridSubs = () => { gridsubscriptions = []; } +const scheduleDetectChanges = (detectChanges: () => void, isDestroyed: () => boolean) => { + let scheduled = false; + return () => { + if (scheduled) { + return; + } + scheduled = true; + setTimeout(() => { + scheduled = false; + if (!isDestroyed()) { + detectChanges(); + } + }); + }; +}; + const waitFor = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export interface GridScrollEventOptions { From 7b07a672699420b2d98c3b9e67c45a152164517a Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 6 Jul 2026 13:08:10 +0300 Subject: [PATCH 14/32] chore(*): remove grid.zoneless tests --- .../grids/grid/src/grid.zoneless.spec.ts | 372 ------------------ 1 file changed, 372 deletions(-) delete mode 100644 projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts deleted file mode 100644 index a5cc8c33790..00000000000 --- a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Zoneless change-detection regression tests for IgxGrid. - * - * Each describe block resets the TestBed and adds provideZonelessChangeDetection() - * so tests run exactly as a consumer app would when Zone.js CD scheduling is absent. - * - * Constraint: after the action under test, fixture.detectChanges() is NOT called. - * Rendered updates must appear via the Angular zoneless scheduler (markForCheck + - * PendingTasks) confirmed with fixture.whenStable(), or via observable / event spies. - * - * Patterns covered - * ──────────────── - * 1. Initial render – grid displays rows on first detectChanges() - * 2. Async data change – sort / filter trigger notifyChanges() → markForCheck() - * → zoneless scheduler → ngDoCheck() → detectChanges() - * 3. Browser callback – filteringDone emitted from a requestAnimationFrame callback - * 4. Horizontal scroll – parentVirtDir.chunkLoad emitted after hScroll event - * (broken: zone.onStable never fires in NoopNgZone) - * 5. fit-content column API – recalculateAutoSizes() and calculateGridSizes() must - * reach autoSizeColumnsInView() without zone.onStable - * - * Patterns NOT covered here (separate PRs) - * ───────────────────────────────────────── - * - Pivot grid auto-size (covered in pivot-grid.spec.ts) - * - IgxForOfDirective/IgxGridForOfDirective zone.onStable fixes — covered by vkombov/fix-17280 - * - IntersectionObserver zone.run fix in grid-base.directive.ts — covered by vkombov/fix-17280 - * - Row editing overlay position (zone.onStable in RowEditPositionStrategy) - * - cachedViewLoaded() virt-state restoration after view recycling during H-scroll - */ - -import { Component, ViewChild, provideZonelessChangeDetection } from '@angular/core'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { By } from '@angular/platform-browser'; -import { SortingDirection, IgxStringFilteringOperand } from 'igniteui-angular/core'; -import { IgxGridComponent } from './grid.component'; -import { IgxColumnComponent } from 'igniteui-angular/grids/core'; -import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; - -// ─── Reusable test host components ────────────────────────────────────────── - -/** Simple grid with ID / Name / LastName columns; data from personIDNameRegionData (7 rows). */ -@Component({ - template: ` - - - - - - `, - standalone: true, - imports: [IgxGridComponent, IgxColumnComponent] -}) -class ZonelessSimpleGridComponent { - @ViewChild('grid', { static: true }) public grid: IgxGridComponent; - public data = SampleTestData.personIDNameRegionData(); -} - -/** - * Wide grid (5 × 200 px columns in a 400 px container) that forces horizontal - * virtualization so we can verify parentVirtDir.chunkLoad after scrolling. - */ -@Component({ - template: ` - - - - - - - - `, - standalone: true, - imports: [IgxGridComponent, IgxColumnComponent] -}) -class ZonelessWideGridComponent { - @ViewChild('grid', { static: true }) public grid: IgxGridComponent; - public data = Array.from({ length: 5 }, (_, i) => ({ - col0: i, col1: i * 2, col2: i * 3, col3: i * 4, col4: i * 5 - })); -} - -/** - * Grid with fit-content columns so hasColumnsToAutosize is true. - * Used to exercise recalculateAutoSizes() and calculateGridSizes() autosize paths. - */ -@Component({ - template: ` - - - - - - `, - standalone: true, - imports: [IgxGridComponent, IgxColumnComponent] -}) -class ZonelessAutoSizeGridComponent { - @ViewChild('grid', { static: true }) public grid: IgxGridComponent; - public data = SampleTestData.personIDNameRegionData(); -} - -// ─── Test suite ───────────────────────────────────────────────────────────── - -describe('IgxGrid - Zoneless Change Detection #grid', () => { - - // ── Pattern 1 & 2: initial render + async data changes ────────────────── - - describe('Basic rendering and async data changes', () => { - beforeEach(async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - }); - - it('should render data rows on initial detectChanges()', async () => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - await fix.whenStable(); - - const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); - expect(rows.length).toBe(7, 'expected all 7 data rows to be rendered'); - }); - - it('should update row order after sort() without calling detectChanges() again', async () => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - await fix.whenStable(); - const grid = fix.componentInstance.grid; - - // personIDNameRegionData has IDs [2,1,6,7,5,4,3]; ascending sort → 1 first - grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false }); - // No fixture.detectChanges() here — the zoneless scheduler must run it - await fix.whenStable(); - - const firstRowCells = fix.debugElement - .query(By.css('igx-grid-row')) - .queryAll(By.css('igx-grid-cell')); - expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('1'); - }); - - it('should update row order after sort() desc without calling detectChanges() again', async () => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - await fix.whenStable(); - const grid = fix.componentInstance.grid; - - grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); - await fix.whenStable(); - - const firstRowCells = fix.debugElement - .query(By.css('igx-grid-row')) - .queryAll(By.css('igx-grid-cell')); - // highest ID is 7 - expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('7'); - }); - - it('should reduce visible rows after filter() without calling detectChanges() again', async () => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - await fix.whenStable(); - const grid = fix.componentInstance.grid; - - // personIDNameRegionData has 2 rows named "Rick" - grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); - await fix.whenStable(); - - const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); - expect(rows.length).toBe(2, 'expected exactly 2 "Rick" rows after filter'); - }); - - it('should restore full row count after clearFilter() without calling detectChanges() again', async () => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - await fix.whenStable(); - const grid = fix.componentInstance.grid; - - grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); - await fix.whenStable(); - - grid.clearFilter('Name'); - await fix.whenStable(); - - const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); - expect(rows.length).toBe(7, 'expected all rows restored after clearFilter'); - }); - }); - - // ── Pattern 3: browser callback (requestAnimationFrame) ───────────────── - - describe('filteringDone event (requestAnimationFrame callback)', () => { - beforeEach(async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - }); - - it('should emit filteringDone after filter() in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - const emittedArgs: any[] = []; - grid.filteringDone.subscribe(args => emittedArgs.push(args)); - - grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); - // requestAnimationFrame is treated as a macrotask in fakeAsync; tick flushes it - tick(16); - - expect(emittedArgs.length).toBe(1, 'filteringDone must emit exactly once'); - expect(emittedArgs[0]).toBeTruthy(); - })); - - it('should emit filteringDone after clearFilter() in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessSimpleGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); - tick(16); - - const emittedArgs: any[] = []; - grid.filteringDone.subscribe(args => emittedArgs.push(args)); - - grid.clearFilter('Name'); - tick(16); - - expect(emittedArgs.length).toBe(1, 'filteringDone must emit after clearFilter'); - })); - }); - - // ── Pattern 4: horizontal-scroll chunkLoad (zone.onStable issue) ──────── - - describe('Horizontal scroll – parentVirtDir.chunkLoad emission', () => { - beforeEach(async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ZonelessWideGridComponent], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - }); - - /** - * Regression: in NoopNgZone (zoneless), zone.onStable never emits. - * horizontalScrollHandler() gated parentVirtDir.chunkLoad.emit() behind - * zone.onStable.pipe(first()).subscribe(), so the event was never raised - * after a horizontal scroll in a zoneless consumer app. - * - * Fix: emit() is no longer gated behind zone lifecycle observables; deferred - * render work goes through the central runAfterRenderOnce() helper instead. - */ - it('should emit parentVirtDir.chunkLoad after horizontal scroll in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessWideGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - const chunkLoadSpy = jasmine.createSpy('parentVirtDir.chunkLoad'); - grid.parentVirtDir.chunkLoad.subscribe(chunkLoadSpy); - - // Trigger the horizontal scroll handler the same way the real scroller does - const hScroller = grid.headerContainer.getScroll(); - hScroller.scrollLeft = 300; - hScroller.dispatchEvent(new Event('scroll')); - tick(100); - - expect(chunkLoadSpy).toHaveBeenCalled(); - })); - - it('should render updated column data after horizontal scroll in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessWideGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - - // Wait for chunkLoad as the reliable signal that virtualization has settled - let chunkLoaded = false; - grid.parentVirtDir.chunkLoad.subscribe(() => { - chunkLoaded = true; - }); - - const hScroller = grid.headerContainer.getScroll(); - hScroller.scrollLeft = 400; - hScroller.dispatchEvent(new Event('scroll')); - tick(100); - - expect(chunkLoaded).toBe(true, 'chunkLoad must emit so the grid knows columns shifted'); - })); - }); - - // ── Pattern 5: fit-content column auto-sizing ──────────────────────────── - - describe('fit-content column auto-sizing', () => { - beforeEach(async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ZonelessAutoSizeGridComponent], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - }); - - /** - * Regression: recalculateAutoSizes() first resets col.autoSize to undefined, - * then gates the re-measurement behind zone.onStable.pipe(first()).subscribe(). - * In NoopNgZone that subscription never fires, so the method silently does nothing. - * - * Fix: the re-measurement is scheduled through the central runAfterRenderOnce() - * helper (afterNextRender), which works in both zoned and zoneless apps. - * - * Note on hasColumnsToAutosize: after the initial render ChromeHeadless measures - * real header widths > 0, so col.autoSize becomes a number and col.width returns - * "Npx", making hasColumnsToAutosize false. recalculateAutoSizes() itself resets - * col.autoSize to undefined before calling the measurement — we only need to verify - * that the measurement is actually invoked, so we spy on the protected method. - */ - it('recalculateAutoSizes() should call autoSizeColumnsInView() in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); - - grid.recalculateAutoSizes(); - tick(16); - - expect(spy).toHaveBeenCalled(); - })); - - /** - * Regression: _zoneBegoneListeners() subscribes to headerContainer.dataChanged and - * gates autoSizeColumnsInView() behind zone.onStable.pipe(first()).subscribe(). - * In NoopNgZone that subscription never fires, so columns are never re-measured - * when the header virtual scroll data changes (column visibility toggle, H-scroll). - * - * Fix: autoSizeColumnsInView() is scheduled through the central - * runAfterRenderOnce() helper (afterNextRender), which works in both - * zoned and zoneless apps. - * - * Setup: hide then show a column, which causes headerContainer.dataChanged to emit. - * The spy is placed between the two visibility changes so only the "show" emission - * is captured. - */ - it('toggling a fit-content column visible should call autoSizeColumnsInView() via dataChanged in zoneless mode', fakeAsync(() => { - const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); - fix.detectChanges(); - tick(16); - - const grid = fix.componentInstance.grid; - const col = grid.getColumnByName('LastName'); - col.hidden = true; - fix.detectChanges(); - tick(16); - - const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); - - // Making the column visible emits headerContainer.dataChanged which must reach - // autoSizeColumnsInView() without zone.onStable in between. - col.hidden = false; - tick(16); - - expect(spy).toHaveBeenCalled(); - })); - }); -}); From 2e37924c544cfc25c7d061bdeb458db274d713dc Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 10 Jul 2026 14:44:41 +0300 Subject: [PATCH 15/32] fix(grid): remove redundant resize change detection --- .../base/grid-filtering-cell.component.ts | 16 ++------------ .../core/src/resizing/resizing.service.ts | 21 ++++--------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts index 135aa5c05cd..07c5311afb5 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts @@ -1,5 +1,4 @@ -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, DoCheck, ElementRef, HostBinding, Input, NgZone, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, HostBinding, Input, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; import { IgxFilteringService } from '../grid-filtering.service'; import { ExpressionUI } from '../excel-style/common'; import { NgClass, NgTemplateOutlet } from '@angular/common'; @@ -7,7 +6,7 @@ import { IBaseChipEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'ign import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxPrefixDirective } from 'igniteui-angular/input-group'; import { IgxBadgeComponent } from 'igniteui-angular/badge'; -import { ColumnType, IFilteringExpression, resizeObservable, ɵSize } from 'igniteui-angular/core'; +import { ColumnType, IFilteringExpression, ɵSize } from 'igniteui-angular/core'; /** * @hidden @@ -29,9 +28,6 @@ import { ColumnType, IFilteringExpression, resizeObservable, ɵSize } from 'igni export class IgxGridFilteringCellComponent implements AfterViewInit, OnInit, DoCheck { public cdr = inject(ChangeDetectorRef); public filteringService = inject(IgxFilteringService); - private zone = inject(NgZone); - private elementRef = inject(ElementRef); - private destroyRef = inject(DestroyRef); @Input() public column: ColumnType; @@ -98,14 +94,6 @@ export class IgxGridFilteringCellComponent implements AfterViewInit, OnInit, DoC public ngAfterViewInit(): void { this.updateFilterCellArea(); - // The visible chips calculation measures the rendered cell, so it must rerun when - // the cell's size actually changes (column/grid resize). ZoneJS apps got this for - // free from zone-triggered ticks; observing the element covers zoneless apps too. - this.zone.runOutsideAngular(() => { - resizeObservable(this.elementRef.nativeElement) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.zone.run(() => this.cdr.markForCheck())); - }); } public ngDoCheck() { diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts index 975dc67c425..4bb2cc39758 100644 --- a/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts +++ b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts @@ -1,5 +1,5 @@ -import { inject, Injectable, Injector, NgZone } from '@angular/core'; -import { ColumnType, runAfterRenderOnce } from 'igniteui-angular/core'; +import { inject, Injectable, NgZone } from '@angular/core'; +import { ColumnType } from 'igniteui-angular/core'; /** * @hidden @@ -8,7 +8,6 @@ import { ColumnType, runAfterRenderOnce } from 'igniteui-angular/core'; @Injectable() export class IgxColumnResizingService { private zone = inject(NgZone); - private injector = inject(Injector); /** @@ -39,18 +38,6 @@ export class IgxColumnResizingService { return parseFloat(window.getComputedStyle(this.column.headerCell.nativeElement).width); } - /** - * Notifies the grid that a column width changed from a pointer interaction, which - * Angular does not observe on its own. The empty zone entry preserves the original - * tick timing for ZoneJS apps; the post-render notification covers zoneless apps - * (where entering the zone is a no-op) and lets DOM-measuring checks — e.g. the - * filter cells' visible chips calculation — run against the resized layout. - */ - private notifyResized() { - this.zone.run(() => { }); - runAfterRenderOnce(this.injector, () => this.column.grid.notifyChanges()); - } - /** * @hidden */ @@ -102,7 +89,7 @@ export class IgxColumnResizingService { const currentColWidth = this.getColumnHeaderRenderedWidth(); this.column.width = this.column.getAutoSize(); - this.notifyResized(); + this.zone.run(() => { }); this.column.grid.columnResized.emit({ column: this.column, @@ -133,7 +120,7 @@ export class IgxColumnResizingService { } - this.notifyResized(); + this.zone.run(() => { }); if (currentColWidth !== parseFloat(this.column.width)) { this.column.grid.columnResized.emit({ From 8ca1cfe36a6a4370c2df4d17a9d2005f9a470c29 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 10 Jul 2026 16:10:26 +0300 Subject: [PATCH 16/32] fix(grid): remove unnecessary read option from runAfterRenderOnce calls --- .../igniteui-angular/grids/grid/src/grid-base.directive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index d17929ab7b8..072365289c9 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -7740,7 +7740,7 @@ export abstract class IgxGridBaseDirective implements GridType, this.changeRowEditingOverlayStateOnScroll(this.crudService.rowInEditMode); } }; - runAfterRenderOnce(this.injector, callback, 'read'); + runAfterRenderOnce(this.injector, callback); this.disableTransitions = false; this.hideOverlays(); @@ -7794,7 +7794,7 @@ export abstract class IgxGridBaseDirective implements GridType, requestAnimationFrame(() => { this.autoSizeColumnsInView(); }); - }, 'read'); + }); }); if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); From 92e064d4460bf48a7f0b924385d01bbee4350a65 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 10 Jul 2026 17:08:27 +0300 Subject: [PATCH 17/32] fix(grid): simplify summary row index retrieval in tests --- .../tree-grid/src/tree-grid-summaries.spec.ts | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index f687163eeca..9a71cec1e89 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -16,9 +16,14 @@ import { IgxTreeGridComponent } from './tree-grid.component'; import { IgxSummaryRow, IgxTreeGridRow } from 'igniteui-angular/grids/core'; import { IgxNumberFilteringOperand } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; +import { firstValueFrom } from 'rxjs'; describe('IgxTreeGrid - Summaries #tGrid', () => { const DEBOUNCETIME = 30; + const childSummaryRowIndexes = [6, 7, 12, 13, 16, 22, 24]; + const getChildSummaryRowIndexes = (treeGrid: IgxTreeGridComponent) => treeGrid.dataView + .map((record, index) => treeGrid.isSummaryRow(record) ? index : -1) + .filter(index => index !== -1); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -165,24 +170,20 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { fix.detectChanges(); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); - - let rootSummaryIndex = treeGrid.dataView.length; - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); treeGrid.summaryCalculationMode = 'rootLevelOnly'; fix.detectChanges(); await wait(50); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual([]); treeGrid.summaryCalculationMode = 'childLevelsOnly'; fix.detectChanges(); await wait(50); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); expect(summaryRow).toBeNull(); @@ -191,9 +192,7 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { await wait(50); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); - rootSummaryIndex = treeGrid.dataView.length; - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); }); it('should be able to show/hide summaries for collapsed parent rows runtime', () => { @@ -889,24 +888,20 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { await fix.whenStable(); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); - - let rootSummaryIndex = treeGrid.dataView.length; - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); treeGrid.summaryCalculationMode = 'rootLevelOnly'; await wait(50); await fix.whenStable(); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual([]); treeGrid.summaryCalculationMode = 'childLevelsOnly'; await wait(50); await fix.whenStable(); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); expect(summaryRow).toBeNull(); @@ -915,9 +910,7 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { await fix.whenStable(); verifyTreeBaseSummaries(fix); - expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); - rootSummaryIndex = treeGrid.dataView.length; - expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); }); }); @@ -1763,29 +1756,23 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { it('should render rows correctly after collapse and expand', async () => { const fix = TestBed.createComponent(IgxTreeGridSummariesScrollingComponent); const treeGrid = fix.componentInstance.treeGrid; - setupGridScrollDetection(fix, treeGrid); - fix.detectChanges(); - await wait(30); fix.detectChanges(); + await fix.whenStable(); + + const chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); (treeGrid as any).scrollTo(23, 0, 0); - fix.detectChanges(); - await wait(60); - fix.detectChanges(); + await chunkLoad; + await fix.whenStable(); let row = treeGrid.getRowByKey(15); row.expanded = false; - fix.detectChanges(); - await wait(30); - fix.detectChanges(); + await fix.whenStable(); row = treeGrid.getRowByKey(15); row.expanded = true; - fix.detectChanges(); - await wait(30); - fix.detectChanges(); + await fix.whenStable(); expect(treeGrid.dataRowList.length).toEqual(10); - clearGridSubs(); }); const verifySummaryForRow147 = (fixture, visibleIndex) => { From 12f4c8923b9b9f9b9651c8fbcc743e28dee62faa Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 10 Jul 2026 18:44:03 +0300 Subject: [PATCH 18/32] fix(grid): restore navigation callback contract --- .../grids/grid/src/grid-base.directive.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 072365289c9..04af42f9e8d 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -7817,7 +7817,8 @@ export abstract class IgxGridBaseDirective implements GridType, this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex); - this.executeNavigationCallback(row, visibleColIndex, cb); + const cbArgs = this.getNavigationArguments(row, visibleColIndex); + cb(cbArgs); }); } const dataViewIndex = this._getDataViewIndex(rowIndex); @@ -7828,17 +7829,7 @@ export abstract class IgxGridBaseDirective implements GridType, return; } - this.executeNavigationCallback(row, visibleColIndex, cb); - } - - private executeNavigationCallback(row, visibleColIndex, cb: (args: any) => void) { - if (!row) { - return; - } const args = this.getNavigationArguments(row, visibleColIndex); - if (!args.target) { - return; - } cb(args); } From 666e628a877c1cfca7170f8e0f16356b20bc6067 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 10 Jul 2026 19:47:44 +0300 Subject: [PATCH 19/32] fix(grid): remove zoneless scroll detection setup from tests --- .../src/hierarchical-grid.navigation.spec.ts | 13 +------------ .../test-utils/helper-utils.spec.ts | 14 -------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index c1e0430293c..99f75031a5d 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -6,7 +6,7 @@ import { wait, UIInteractions, waitForSelectionChange } from '../../../test-util import { IgxRowIslandComponent } from './row-island.component'; import { By } from '@angular/platform-browser'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; -import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection, setupHierarchicalGridScrollDetectionZoneless, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; import { IPathSegment } from 'igniteui-angular/core'; @@ -1053,7 +1053,6 @@ describe('IgxHierarchicalGrid Navigation', () => { fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); fixture.detectChanges(); hierarchicalGrid = fixture.componentInstance.hgrid; - setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); baseHGridContent = GridFunctions.getGridContent(fixture); GridFunctions.focusFirstCell(fixture, hierarchicalGrid); })); @@ -1179,7 +1178,6 @@ describe('IgxHierarchicalGrid Navigation', () => { fixture = TestBed.createComponent(IgxHierarchicalGridTestComplexComponent); fixture.detectChanges(); hierarchicalGrid = fixture.componentInstance.hgrid; - setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); baseHGridContent = GridFunctions.getGridContent(fixture); GridFunctions.focusFirstCell(fixture, hierarchicalGrid); })); @@ -1298,7 +1296,6 @@ describe('IgxHierarchicalGrid Navigation', () => { fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); fixture.detectChanges(); hierarchicalGrid = fixture.componentInstance.hgrid; - setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); baseHGridContent = GridFunctions.getGridContent(fixture); GridFunctions.focusFirstCell(fixture, hierarchicalGrid); })); @@ -1383,7 +1380,6 @@ describe('IgxHierarchicalGrid Navigation', () => { fixture = TestBed.createComponent(IgxHierarchicalGridSmallerChildComponent); fixture.detectChanges(); hierarchicalGrid = fixture.componentInstance.hgrid; - setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); baseHGridContent = GridFunctions.getGridContent(fixture); GridFunctions.focusFirstCell(fixture, hierarchicalGrid); })); @@ -1449,7 +1445,6 @@ describe('IgxHierarchicalGrid Navigation', () => { fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); fixture.detectChanges(); hierarchicalGrid = fixture.componentInstance.hgrid; - setupHierarchicalGridScrollDetectionZoneless(fixture, hierarchicalGrid); baseHGridContent = GridFunctions.getGridContent(fixture); GridFunctions.focusFirstCell(fixture, hierarchicalGrid); })); @@ -1496,12 +1491,6 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(childGridNested).not.toBe(undefined); expect(initializedChildGrid).toBe(childGridComponent); expect(initializedNestedChildGrid).toBe(childGridNestedComponent); - await waitForGridSettle(fixture, () => { - const elementRect = childGridNested.getBoundingClientRect(); - const parentRect = childGrid.getBoundingClientRect(); - return elementRect.bottom >= parentRect.top - 1 && - elementRect.top <= parentRect.bottom + 1; - }); expectElementInView(childGridNested, childGrid); }); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index e753c1b005c..f3f83024676 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -57,20 +57,6 @@ export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { - setupGridScrollDetectionZoneless(fixture, hierarchicalGrid); - - const existingChildren = hierarchicalGrid.gridAPI.getChildGrids(true); - existingChildren.forEach(child => setupGridScrollDetectionZoneless(fixture, child)); - - const layouts = hierarchicalGrid.allLayoutList.toArray(); - layouts.forEach((layout) => { - gridsubscriptions.push(layout.gridCreated.subscribe(evt => { - setupGridScrollDetectionZoneless(fixture, evt.grid); - })); - }); -}; - export const clearGridSubs = () => { gridsubscriptions.forEach(sub => sub.unsubscribe()); gridsubscriptions = []; From 6f38e2c9e8d60423295f2ac67d50a916ed9496f5 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 13 Jul 2026 12:18:18 +0300 Subject: [PATCH 20/32] fix(grid): waitForChildrenResolved function to include predicate handling --- .../src/app/custom-strategy.spec.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 52ca03a8374..7d0dc03943a 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,6 +1,6 @@ import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent } from 'igniteui-angular'; import { html } from 'lit'; -import { firstValueFrom, fromEvent, skip, timer } from 'rxjs'; +import { filter, firstValueFrom, fromEvent, skip, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; import hgridData from '../assets/data/projects-hgrid.js'; import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; @@ -19,8 +19,12 @@ import { defineComponents } from '../utils/register'; describe('Elements: ', () => { let testContainer: HTMLDivElement; - const waitForChildrenResolved = (element: IgcNgElement) => - firstValueFrom(fromEvent(element, 'childrenResolved')); + const waitForChildrenResolved = async (element: IgcNgElement, predicate: () => boolean = () => true) => { + if (predicate()) { + return; + } + await firstValueFrom(fromEvent(element, 'childrenResolved').pipe(filter(() => predicate()))); + }; beforeAll(async () =>{ defineComponents( @@ -208,18 +212,18 @@ describe('Elements: ', () => { testContainer.innerHTML = innerHtml; const grid = document.querySelector>('#testGrid'); - await firstValueFrom(fromEvent(grid, "childrenResolved")); + await waitForChildrenResolved(grid, () => grid.columns.length === 8); const thirdGroup = document.querySelector('igc-column-layout[header="Product Stock"]'); const secondGroup = document.querySelector('igc-column-layout[header="Product Details"]'); - await waitForChildrenResolved(grid); expect(grid.columns.length).toEqual(8); expect(grid.getColumnByName('ProductID')).toBeTruthy(); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); + const columnsRemoved = waitForChildrenResolved(grid, () => grid.columns.length === 4); grid.removeChild(secondGroup); - await firstValueFrom(fromEvent(grid, "childrenResolved")); + await columnsRemoved; expect(grid.columns.length).toEqual(4); expect(grid.getColumnByName('ProductID')).toBeTruthy(); @@ -230,8 +234,9 @@ describe('Elements: ', () => { const newColumn = document.createElement('igc-column'); newColumn.setAttribute('field', 'ProductName'); newGroup.appendChild(newColumn); + const columnsInserted = waitForChildrenResolved(grid, () => grid.columns.length === 6); grid.insertBefore(newGroup, thirdGroup); - await firstValueFrom(fromEvent(grid, "childrenResolved")); + await columnsInserted; expect(grid.columns.length).toEqual(6); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); From fe65e80c531b069227bffadd2a1fa1cb081191e1 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Tue, 14 Jul 2026 10:43:35 +0300 Subject: [PATCH 21/32] fix(grid): remove unnecessary 'read' argument from recalcUpdateSizes calls --- .../directives/src/directives/for-of/for_of.directive.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 310983916bf..059443a4b27 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -961,7 +961,7 @@ export class IgxForOfDirective extends IgxForOfToken { this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; }, 'write'); - runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(), 'read'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes()); this.dc.changeDetectorRef.detectChanges(); if (prevStartIndex !== this.state.startIndex) { @@ -1173,7 +1173,7 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes(), 'read'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes()); this.dc.changeDetectorRef.detectChanges(); if (prevStartIndex !== this.state.startIndex) { @@ -1779,7 +1779,7 @@ export class IgxGridForOfDirective extends IgxForOfDirec runAfterRenderOnce(this._injector, () => { this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; }, 'write'); - runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(prevState), 'read'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(prevState)); this.cdr.markForCheck(); } From 67902c8c3091bda4368ea6ff4574091beac0af44 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Tue, 14 Jul 2026 11:27:46 +0300 Subject: [PATCH 22/32] fix(grid): remove waitForGridSettle calls --- .../grids/grid/src/grid.component.spec.ts | 12 ++++++------ .../pivot-grid/src/pivot-grid-keyboard-nav.spec.ts | 9 ++------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 64c616c5a9c..371996a6b9c 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -15,7 +15,7 @@ import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabItemComponent, Igx import { IgxGridRowComponent } from './grid-row.component'; import { GRID_SCROLL_CLASS, GridFunctions } from '../../../test-utils/grid-functions.spec'; import { AsyncPipe } from '@angular/common'; -import { setElementSize, waitForGridSettle, ymd } from '../../../test-utils/helper-utils.spec'; +import { setElementSize, ymd } from '../../../test-utils/helper-utils.spec'; import { FilteringExpressionsTree, FilteringLogic, getComponentSize, GridColumnDataType, IgxNumberFilteringOperand, IgxStringFilteringOperand, ISortingExpression, ɵSize, SortingDirection } from 'igniteui-angular/core'; import { IgxPaginatorComponent, IgxPaginatorContentDirective } from 'igniteui-angular/paginator'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../src/grid-base.directive'; @@ -2078,7 +2078,7 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.columns[2].field).toBe('lastName'); })); - it('should set correct aria attributes related to total rows/cols count and indexes', async () => { + fit('should set correct aria attributes related to total rows/cols count and indexes', async () => { const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); fix.componentInstance.initColumnsRows(80, 20); fix.detectChanges(); @@ -2089,9 +2089,10 @@ describe('IgxGrid Component Tests #grid', () => { const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); grid.navigateTo(50, 16); - // navigateTo scrolls the virtualized grid across several async render passes; - // wait for the target cell to render instead of racing it with a fixed delay. - await waitForGridSettle(fix, () => !!grid.gridAPI.get_cell_by_index(50, 'col16')); + await fix.whenStable(); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); expect(headerRowElement.getAttribute('aria-rowindex')).toBe('1'); expect(grid.nativeElement.getAttribute('aria-rowcount')).toBe('81'); @@ -2116,7 +2117,6 @@ describe('IgxGrid Component Tests #grid', () => { const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); fix.componentInstance.initColumnsRows(80, 20); fix.detectChanges(); - fix.detectChanges(); const grid = fix.componentInstance.grid; const gridHeader = GridFunctions.getGridHeader(grid); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts index 16b57a0306a..7c2caa4887d 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts @@ -4,7 +4,6 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestBaseComponent } from '../../../test-utils/pivot-grid-samples.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { waitForGridSettle } from '../../../test-utils/helper-utils.spec'; import { IgxPivotGridComponent } from './pivot-grid.component'; import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; import { DebugElement } from '@angular/core'; @@ -332,12 +331,8 @@ describe('IgxPivotGrid - Keyboard navigation #pivotGrid', () => { const gridContent = GridFunctions.getGridContent(fixture); UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); - // Horizontal navigation scrolls the virtualized columns before activating the - // next cell; wait for that to settle instead of racing it with a fixed delay. - await waitForGridSettle(fixture, () => { - const active = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); - return active.length === 1 && active[0].componentInstance.column.field === 'Stanley-UnitPrice'; - }); + await wait(30); + fixture.detectChanges(); activeCells = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); expect(activeCells.length).toBe(1); From 5a8f326d09b33e4893a16daf1983ea29ac2a216f Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Tue, 14 Jul 2026 13:18:15 +0300 Subject: [PATCH 23/32] test(grid): improve virtualized navigation synchronization --- .../grid/src/grid-cell-selection.spec.ts | 25 ++- .../grid/src/grid-keyBoardNav-headers.spec.ts | 13 +- .../grid/src/grid-mrl-keyboard-nav.spec.ts | 26 ++- .../grids/grid/src/grid.component.spec.ts | 2 +- .../grids/grid/src/grid.master-detail.spec.ts | 5 +- .../src/hierarchical-grid.navigation.spec.ts | 160 +++++++++------ .../hierarchical-grid.virtualization.spec.ts | 10 +- .../src/tree-grid-keyBoardNav.spec.ts | 32 ++- .../tree-grid/src/tree-grid-summaries.spec.ts | 3 +- .../test-utils/helper-utils.spec.ts | 192 +++++------------- 10 files changed, 228 insertions(+), 240 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts index 19c0354fc3c..4738e5d4de8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -10,7 +10,7 @@ import { IgxGridRowEditingWithoutEditableColumnsComponent } from '../../../test-utils/grid-samples.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; import { GridSelectionMode } from 'igniteui-angular/grids/core'; import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; @@ -1451,14 +1451,23 @@ describe('IgxGrid - Cell selection #grid', () => { await wait(); fix.detectChanges(); - expect(selectionChangeSpy).toHaveBeenCalledTimes(0); - GridSelectionFunctions.verifyCellSelected(firstCell); - expect(grid.selectedCells.length).toBe(1); + const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); + const horizontalScroll = waitForGridScroll(fix, grid, 'horizontal'); - UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); - // Ctrl+End scrolls the virtualized grid before the range is finalized; wait - // for the selection to settle instead of racing it with a fixed delay. - await waitForGridSettle(fix, () => selectionChangeSpy.calls.count() >= 1); + UIInteractions.triggerKeyDownEvtUponElem( + 'end', + firstCell.nativeElement, + true, + false, + true, + true + ); + + await verticalScroll; + await horizontalScroll; + + // Render the final activation/selection state. + fix.detectChanges(); expect(selectionChangeSpy).toHaveBeenCalledTimes(1); GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts index d1874d10047..9c426589b28 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts @@ -4,7 +4,7 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { IgxGridComponent } from './grid.component'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection, waitForGridSettle } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; import { SelectionWithScrollsComponent, MRLTestComponent, @@ -63,14 +63,15 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { }); it('should focus first header when the grid is scrolled', async () => { + const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); + const horizontalScroll = waitForGridScroll(fix, grid, 'horizontal'); grid.navigateTo(7, 5); - // navigateTo scrolls both axes; drive the deferred scroll processing so the - // columns land in view before focusing the header. - await dispatchGridScrollEvents(fix, grid, { waitMs: 250 }); + await verticalScroll; + await horizontalScroll; gridHeader.nativeElement.focus(); //('focus', {}); - await waitForGridSettle(fix, () => - grid.navigation.activeNode?.row === -1 && grid.navigation.activeNode?.column === 3); + await wait(250); + fix.detectChanges(); const header = GridFunctions.getColumnHeader('ID', fix); expect(header).not.toBeDefined(); diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index e3e5fef6ce2..7f226eff589 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -6,13 +6,14 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, navigateWithGridScroll, setupGridScrollDetection, setupGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, navigateWithGridScroll, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; import { CellType, IGridCellEventArgs, IgxColumnComponent, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../src/grid-base.directive'; +import { firstValueFrom } from 'rxjs'; const DEBOUNCE_TIME = 60; const CELL_CSS_CLASS = '.igx-grid__td'; @@ -1941,7 +1942,9 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow down - await navigateWithGridScroll(fix, grid, 'ArrowDown', { axis: 'vertical' }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Phone'); @@ -1958,7 +1961,9 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow up - await navigateWithGridScroll(fix, grid, 'ArrowUp', { axis: 'vertical' }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); @@ -1975,7 +1980,12 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - await navigateWithGridScroll(fix, grid, 'ArrowRight'); + let verticalChunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await verticalChunkLoad; + fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Address'); @@ -1992,7 +2002,12 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow left - await navigateWithGridScroll(fix, grid, 'ArrowLeft'); + verticalChunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await verticalChunkLoad; + fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); @@ -2692,7 +2707,6 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation in zoneless change dete await wait(DEBOUNCE_TIME); fix.detectChanges(); - setupGridScrollDetectionZoneless(fix, fix.componentInstance.grid); // last cell from first layout const lastCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 371996a6b9c..45b14362eee 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -2078,7 +2078,7 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.columns[2].field).toBe('lastName'); })); - fit('should set correct aria attributes related to total rows/cols count and indexes', async () => { + it('should set correct aria attributes related to total rows/cols count and indexes', async () => { const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); fix.componentInstance.initColumnsRows(80, 20); fix.detectChanges(); diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index c198e798249..f0380b49705 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -10,7 +10,7 @@ import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; import { IgxGridExpandableCellComponent } from './expandable-cell.component'; import { GridSummaryPosition, GridSelectionMode, CellType, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; -import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; import { GridSummaryCalculationMode, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; @@ -675,9 +675,10 @@ describe('IgxGrid Master Detail #grid', () => { UIInteractions.simulateClickAndSelectEvent(targetCellElement); const activeNodeChange = waitForActiveNodeChange(grid); + const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); - await dispatchGridScrollEvents(fix, grid, { waitMs: DEBOUNCE_TIME, waitForChunkLoad: true }); + await verticalScroll; await activeNodeChange; const fRow = grid.gridAPI.get_row_by_index(0); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index 99f75031a5d..77fc7cfd28a 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -6,24 +6,18 @@ import { wait, UIInteractions, waitForSelectionChange } from '../../../test-util import { IgxRowIslandComponent } from './row-island.component'; import { By } from '@angular/platform-browser'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; -import { clearGridSubs, dispatchGridScrollEvents, setupHierarchicalGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupHierarchicalGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { GridType, IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; import { IPathSegment } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; -import { filter, firstValueFrom } from 'rxjs'; +import { debounceTime, filter, firstValueFrom, merge, tap } from 'rxjs'; import { IGridCreatedEventArgs } from './events'; const DEBOUNCE_TIME = 60; const GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; const GRID_FOOTER_CLASS = '.igx-grid__tfoot'; -const settleGridScrollEvents = async (fixture, ...grids) => { - for (const grid of grids) { - await dispatchGridScrollEvents(fixture, grid, { waitMs: DEBOUNCE_TIME }); - } -}; - const waitForInitializedGrid = (rowIsland: IgxRowIslandComponent, parentID: any) => firstValueFrom(rowIsland.gridInitialized.pipe(filter((event: IGridCreatedEventArgs) => event.parentID === parentID))); @@ -37,6 +31,43 @@ const expectElementInView = (element: HTMLElement, parent: HTMLElement) => { expect(elementRect.top).toBeLessThanOrEqual(parentRect.bottom + 1); }; +const runGridAction = async ( + fixture, + grids: GridType[], + action: () => Promise +) => { + const scrollSubscriptions = []; + const gridCreatedSubscriptions = []; + const subscribeToScroll = (grid: GridType) => { + scrollSubscriptions.push(grid.gridScroll.subscribe(() => fixture.detectChanges())); + }; + + grids.forEach((grid) => { + subscribeToScroll(grid); + if (grid instanceof IgxHierarchicalGridComponent) { + grid.allLayoutList.forEach(layout => + gridCreatedSubscriptions.push(layout.gridCreated.subscribe(event => subscribeToScroll(event.grid)))); + } + }); + + try { + await action(); + } finally { + scrollSubscriptions.forEach(subscription => subscription.unsubscribe()); + gridCreatedSubscriptions.forEach(subscription => subscription.unsubscribe()); + } +}; + +const navigateAndWaitForScroll = async (fixture, grids: GridType[], navigate: () => void) => { + const scrollComplete = firstValueFrom(merge(...grids.map(grid => grid.gridScroll)).pipe( + tap(() => fixture.detectChanges()), + debounceTime(DEBOUNCE_TIME) + )); + navigate(); + await scrollComplete; + fixture.detectChanges(); +}; + describe('IgxHierarchicalGrid Navigation', () => { let fixture; let hierarchicalGrid: IgxHierarchicalGridComponent; @@ -176,8 +207,8 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, childCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); - await settleGridScrollEvents(fixture, childGrid, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true)); // verify selection in child. const selectedCell = fixture.componentInstance.selectedCell; @@ -190,22 +221,24 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { + const initialParentScroll = waitForGridScroll(fixture, hierarchicalGrid, 'vertical'); hierarchicalGrid.verticalScrollContainer.scrollTo(2); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await initialParentScroll; const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; const horizontalScrDir = childGrid.dataRowList.toArray()[0].virtDirRow; + const initialChildScroll = waitForGridScroll(fixture, childGrid, 'horizontal'); horizontalScrDir.scrollTo(6); - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); + await initialChildScroll; - const childLastCell = childGrid.dataRowList.toArray()[9].cells.toArray()[3]; + const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'childData2'); GridFunctions.focusCell(fixture, childLastCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); - await settleGridScrollEvents(fixture, childGrid, hierarchicalGrid); + const parentVerticalScroll = waitForGridScroll(fixture, hierarchicalGrid, 'vertical'); + await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true)); + await parentVerticalScroll; const selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.value).toEqual(0); @@ -387,10 +420,8 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.dataRowList.first.cells.first; GridFunctions.focusCell(fixture, parentCell); - const selectionChange = waitForSelectionChange(hierarchicalGrid); - UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); - await settleGridScrollEvents(fixture, hierarchicalGrid); - await selectionChange; + await navigateAndWaitForScroll(fixture, [hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true)); const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); const selectedCell = fixture.componentInstance.selectedCell; @@ -545,8 +576,8 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, fchildRowCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - await settleGridScrollEvents(fixture, child1); + await navigateAndWaitForScroll(fixture, [child1], () => + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false)); // second child row should be in view const sChildRowCell = child1.getRowByIndex(2).cells[0]; @@ -555,8 +586,8 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await settleGridScrollEvents(fixture, child1); + await navigateAndWaitForScroll(fixture, [child1], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.row.index).toBe(0); @@ -700,8 +731,8 @@ describe('IgxHierarchicalGrid Navigation', () => { // navigate up const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - await settleGridScrollEvents(fixture, nestedChild, child, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [nestedChild, child, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false)); let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -715,8 +746,8 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(nextCell.rowIndex).toBe(0); // navigate up into parent - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - await settleGridScrollEvents(fixture, nestedChild, child, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [nestedChild, child, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false)); nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -736,8 +767,8 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, nestedChildCell); const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); - await settleGridScrollEvents(fixture, nestedChild, child); + await navigateAndWaitForScroll(fixture, [nestedChild, child], () => + UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false)); // check if parent has scrolled down to show focused cell. expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); @@ -766,8 +797,8 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); GridFunctions.focusCell(fixture, parentCell); - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); - await settleGridScrollEvents(fixture, child, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [child, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false)); const nestedChild = child.gridAPI.getChildGrids(false)[5]; const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); @@ -804,8 +835,8 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, child2Cell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await settleGridScrollEvents(fixture, child2, child1, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [child2, child1, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); const lastCellPrevRI = child1.dataRowList.last.cells.first; @@ -844,9 +875,9 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, parentCell); // Arrow Up into prev child grid - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; - await settleGridScrollEvents(fixture, child2, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [child2, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false)); const child2Cell = child2.dataRowList.last.cells.first; expect(child2Cell.selected).toBe(true); @@ -885,8 +916,8 @@ describe('IgxHierarchicalGrid Navigation', () => { let childLastCell = childGrid.selectedCells; expect(childLastCell.length).toBe(0); - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await settleGridScrollEvents(fixture, childGrid2, childGrid, hierarchicalGrid); + await navigateAndWaitForScroll(fixture, [childGrid2, childGrid, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); childLastCell = childGrid.selectedCells; expect(childLastCell.length).toBe(1); @@ -934,12 +965,12 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'Col2'); GridFunctions.focusCell(fixture, parentCell); - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); - await settleGridScrollEvents(fixture, hierarchicalGrid); - - // last cell in child should be focused const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false)); + + // last cell in child should be focused const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'ProductName'); expect(childLastCell.selected).toBe(true); @@ -951,15 +982,26 @@ describe('IgxHierarchicalGrid Navigation', () => { const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - firstChildGrid.verticalScrollContainer.scrollTo(9); - await settleGridScrollEvents(fixture, firstChildGrid); - - const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); + let firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); + if (!firstChildCell) { + const firstChildVerticalScroll = waitForGridScroll(fixture, firstChildGrid, 'vertical'); + const scrollElement = firstChildGrid.verticalScrollContainer.getScroll(); + scrollElement.scrollTop = scrollElement.scrollHeight; + await firstChildVerticalScroll; + firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); + } GridFunctions.focusCell(fixture, firstChildCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - await settleGridScrollEvents(fixture, firstChildGrid, secondChildGrid, hierarchicalGrid); + await runGridAction(fixture, [firstChildGrid, secondChildGrid, hierarchicalGrid], async () => { + const activeNodeChange = firstValueFrom(secondChildGrid.activeNodeChange); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + const targetCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + if (!targetCell?.active) { + await activeNodeChange; + } + fixture.detectChanges(); + }); const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); @@ -995,8 +1037,8 @@ describe('IgxHierarchicalGrid Navigation', () => { rowID: 10 }; await wait(16); - hierarchicalGrid.navigation.navigateToChildGrid([path]); - await settleGridScrollEvents(fixture, hierarchicalGrid); + await runGridAction(fixture, [hierarchicalGrid], () => + new Promise(resolve => hierarchicalGrid.navigation.navigateToChildGrid([path], resolve))); fixture.detectChanges(); const childGrid = hierarchicalGrid.gridAPI.getChildGrid([path]).nativeElement; expect(childGrid).not.toBe(undefined); @@ -1025,18 +1067,20 @@ describe('IgxHierarchicalGrid Navigation', () => { const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); - hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); - await settleGridScrollEvents(fixture, hierarchicalGrid); - await childGridInitialized; + await runGridAction(fixture, [hierarchicalGrid], async () => { + const navigationComplete = new Promise(resolve => + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], resolve)); + await childGridInitialized; + await nestedChildGridInitialized; + await navigationComplete; + }); const childGridComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }]) as IgxHierarchicalGridComponent; const childGrid = childGridComponent.nativeElement; expect(childGrid).not.toBe(undefined); - await settleGridScrollEvents(fixture, hierarchicalGrid); - await nestedChildGridInitialized; const childGridNestedComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }, { ...targetNested }]) as IgxHierarchicalGridComponent; const childGridNested = childGridNestedComponent.nativeElement; expect(childGridNested).not.toBe(undefined); - await settleGridScrollEvents(fixture, childGridComponent, hierarchicalGrid); + fixture.detectChanges(); expectElementInView(childGridNested, childGrid); }); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index 15a5113f0d0..2e14c94b8b9 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -589,7 +589,7 @@ describe('IgxHierarchicalGrid Virtualization zoneless change detection #hGrid', let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; - await Promise.race([chunkLoad, wait(500)]); + await chunkLoad; await fixture.whenStable(); (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); @@ -602,12 +602,12 @@ describe('IgxHierarchicalGrid Virtualization zoneless change detection #hGrid', chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; - await Promise.race([chunkLoad, wait(500)]); + await chunkLoad; await fixture.whenStable(); chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; - await Promise.race([chunkLoad, wait(500)]); + await chunkLoad; await fixture.whenStable(); const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; @@ -653,14 +653,14 @@ describe('IgxHierarchicalGrid Virtualization zoneless change detection #hGrid', // scroll down so the expanded row is recycled out of view let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); elem.scrollTop = 1000; - await Promise.race([chunkLoad, wait(500)]); + await chunkLoad; await fixture.whenStable(); expect(firstRow.expanded).toBeFalsy(); // scroll back to top chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); elem.scrollTop = 0; - await Promise.race([chunkLoad, wait(500)]); + await chunkLoad; await fixture.whenStable(); expect(firstRow.expanded).toBeTruthy(); }); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 095c666b628..66276d15740 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -4,7 +4,7 @@ import { IgxTreeGridComponent } from './public_api'; import { IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, dispatchGridScrollEvents, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridNavigation, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { firstValueFrom } from 'rxjs'; @@ -423,7 +423,8 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 5; i < 9; i++) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await wait(DEBOUNCETIME); + fix.detectChanges(); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -432,7 +433,8 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 9; i > 0; i--) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await wait(DEBOUNCETIME); + fix.detectChanges(); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -560,15 +562,21 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + let verticalScroll = waitForGridScroll(fix, treeGrid, 'vertical'); + let horizontalScroll = waitForGridScroll(fix, treeGrid, 'horizontal'); UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await verticalScroll; + await horizontalScroll; cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + verticalScroll = waitForGridScroll(fix, treeGrid, 'vertical'); + horizontalScroll = waitForGridScroll(fix, treeGrid, 'horizontal'); UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await verticalScroll; + await horizontalScroll; cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -659,16 +667,22 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { } let newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await waitForGridNavigation( + fix, + treeGrid, + () => UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent) + ); newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); expect(newCell.editMode).toBe(true); expect( treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); - await dispatchGridScrollEvents(fix, treeGrid, { waitMs: DEBOUNCETIME }); + await waitForGridNavigation( + fix, + treeGrid, + () => UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true) + ); newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); expect(newCell.editMode).toBe(true); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index 9a71cec1e89..b34d966bc86 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -8,7 +8,7 @@ import { IgxTreeGridSummariesScrollingComponent, IgxTreeGridSummariesKeyScroliingComponent } from '../../../test-utils/tree-grid-components.spec'; -import { clearGridSubs, setupGridScrollDetection, setupGridScrollDetectionZoneless } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; @@ -876,7 +876,6 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { fix = TestBed.createComponent(IgxTreeGridSummariesKeyComponent); fix.detectChanges(); treeGrid = fix.componentInstance.treeGrid; - setupGridScrollDetectionZoneless(fix, treeGrid); }); afterEach(() => { diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index f3f83024676..a026e8326d0 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -1,8 +1,8 @@ import { EventEmitter, NgZone, Injectable } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; -import { GridType } from 'igniteui-angular/grids/core'; +import { GridType, IGridScrollEventArgs } from 'igniteui-angular/grids/core'; import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; -import { firstValueFrom, Subscription } from 'rxjs'; +import { filter, firstValueFrom, Subscription } from 'rxjs'; import { GridFunctions } from './grid-functions.spec'; /** @@ -33,16 +33,6 @@ export const setupGridScrollDetection = (fixture: ComponentFixture, grid: G gridsubscriptions.push(grid.selected.subscribe(() => grid.cdr.detectChanges())); }; -export const setupGridScrollDetectionZoneless = (fixture: ComponentFixture, grid: GridType) => { - const isFixtureDestroyed = () => fixture.componentRef.hostView.destroyed; - const scheduleFixtureDetectChanges = scheduleDetectChanges(() => fixture.detectChanges(), isFixtureDestroyed); - const scheduleGridDetectChanges = scheduleDetectChanges(() => grid.cdr.detectChanges(), isFixtureDestroyed); - gridsubscriptions.push(grid.verticalScrollContainer.chunkLoad.subscribe(scheduleFixtureDetectChanges)); - gridsubscriptions.push(grid.parentVirtDir.chunkLoad.subscribe(scheduleFixtureDetectChanges)); - gridsubscriptions.push(grid.activeNodeChange.subscribe(scheduleGridDetectChanges)); - gridsubscriptions.push(grid.selected.subscribe(scheduleGridDetectChanges)); -}; - export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { setupGridScrollDetection(fixture, hierarchicalGrid); @@ -62,162 +52,78 @@ export const clearGridSubs = () => { gridsubscriptions = []; } -const scheduleDetectChanges = (detectChanges: () => void, isDestroyed: () => boolean) => { - let scheduled = false; - return () => { - if (scheduled) { - return; - } - scheduled = true; - setTimeout(() => { - scheduled = false; - if (!isDestroyed()) { - detectChanges(); - } - }); - }; -}; - -const waitFor = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -export interface GridScrollEventOptions { - waitMs?: number; - waitForChunkLoad?: boolean; -} - -export const dispatchGridVerticalScroll = async ( - fixture: ComponentFixture, - grid: GridType, - options: GridScrollEventOptions = {} -) => { - const { waitMs = 60, waitForChunkLoad = false } = options; - const chunkLoad = waitForChunkLoad ? firstValueFrom(grid.verticalScrollContainer.chunkLoad) : null; - grid.verticalScrollContainer.getScroll().dispatchEvent(new Event('scroll')); - await waitFor(waitMs); - fixture.detectChanges(); - await chunkLoad; -}; +export type GridScrollDirection = 'vertical' | 'horizontal'; -export const dispatchGridHorizontalScroll = async ( +/** + * Resolves after a real grid scroll event and its deferred chunk render complete. + * Call this before the interaction that causes the scroll so neither event is missed. + * Existing ZoneJS fixtures require one explicit render between those boundaries. + */ +export const waitForGridScroll = async ( fixture: ComponentFixture, grid: GridType, - options: GridScrollEventOptions = {} -) => { - const { waitMs = 60, waitForChunkLoad = false } = options; - const chunkLoad = waitForChunkLoad ? firstValueFrom(grid.parentVirtDir.chunkLoad) : null; - grid.headerContainer.getScroll().dispatchEvent(new Event('scroll')); - await waitFor(waitMs); + direction: GridScrollDirection +): Promise => { + const scrollEvent = firstValueFrom(grid.gridScroll.pipe( + filter((event: IGridScrollEventArgs) => event.direction === direction) + )); + const chunkLoad = firstValueFrom( + direction === 'vertical' ? grid.verticalScrollContainer.chunkLoad : grid.parentVirtDir.chunkLoad + ); + + await scrollEvent; fixture.detectChanges(); await chunkLoad; }; -export const dispatchGridScrollEvents = async ( - fixture: ComponentFixture, - grid: GridType, - options: GridScrollEventOptions = {} -) => { - await dispatchGridVerticalScroll(fixture, grid, options); - await dispatchGridHorizontalScroll(fixture, grid, options); -}; - /** - * Simulates a grid-content keyboard navigation that scrolls the grid, and resolves once - * the resulting `activeNodeChange` has fired. + * Runs a grid navigation and resolves once the resulting `activeNodeChange` has fired. * * The `activeNodeChange` subscription is created before the keydown so a synchronous emit - * is not missed; the dispatched scroll events (plus their change detection) drive the - * deferred post-render work — the scrolled-in row/cell renders and the navigation - * continuation activates the target cell — without racing a fixed delay. - * - * @param axis which scroll direction(s) the navigation triggers; use `'vertical'` for - * up/down navigation and `'both'` (default) for Home/End/Ctrl combinations that can also - * scroll horizontally. + * is not missed. Real scroll events drive the fixture render needed for deferred + * post-render work without requiring the test to predict whether or which axis scrolls. */ -export const navigateWithGridScroll = async ( +export const waitForGridNavigation = async ( fixture: ComponentFixture, grid: GridType, - key: string, - { ctrlKey = false, axis = 'both', waitMs = 60 }: { ctrlKey?: boolean; axis?: 'vertical' | 'both'; waitMs?: number } = {} + navigate: () => void ): Promise => { + const scrollSubscription = grid.gridScroll.subscribe(() => fixture.detectChanges()); const activeNodeChange = firstValueFrom(grid.activeNodeChange); - GridFunctions.simulateGridContentKeydown(fixture, key, false, false, ctrlKey); - if (axis === 'vertical') { - await dispatchGridVerticalScroll(fixture, grid, { waitMs }); - } else { - await dispatchGridScrollEvents(fixture, grid, { waitMs }); + + try { + navigate(); + await activeNodeChange; + fixture.detectChanges(); + } finally { + scrollSubscription.unsubscribe(); } - await activeNodeChange; - fixture.detectChanges(); }; -/** - * Sets the grid's vertical scroll position and resolves once the resulting `chunkLoad` - * has fired, running change-detection passes while waiting. The `chunkLoad` subscription - * is created before the scroll to avoid missing a synchronous emit, and the interleaved - * renders let the (deferred) emit run without racing a fixed delay. - */ -export const setGridVerticalScrollTop = async ( +/** Simulates keyboard navigation and waits for its final active-node update. */ +export const navigateWithGridScroll = async ( fixture: ComponentFixture, grid: GridType, - scrollTop: number, - { maxAttempts = 30, intervalMs = 20 }: { maxAttempts?: number; intervalMs?: number } = {} + key: string, + { ctrlKey = false }: { ctrlKey?: boolean } = {} ): Promise => { - const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); - let loaded = false; - void chunkLoad.then(() => { - loaded = true; - }); - grid.verticalScrollContainer.getScroll().scrollTop = scrollTop; - for (let attempt = 0; attempt < maxAttempts && !loaded; attempt++) { - await new Promise(resolve => setTimeout(resolve, intervalMs)); - fixture.detectChanges(); - await fixture.whenStable(); - } - if (!loaded) { - throw new Error(`setGridVerticalScrollTop: chunkLoad did not fire within ${maxAttempts} attempts.`); - } - await chunkLoad; - fixture.detectChanges(); + await waitForGridNavigation( + fixture, + grid, + () => GridFunctions.simulateGridContentKeydown(fixture, key, false, false, ctrlKey) + ); }; -/** - * Polls until `predicate` returns truthy or the attempt budget is exhausted, running a - * change-detection pass on every tick. Use it for interactions whose visible result - * settles over several asynchronous render passes — e.g. keyboard navigation that first - * scrolls a virtualized grid and only then activates/selects the target cell — instead - * of a single fixed `wait(...)` that races the render cascade. - */ -export const waitForGridSettle = async ( +/** Sets vertical scroll position and resolves after the real scroll and chunk events. */ +export const setGridVerticalScrollTop = async ( fixture: ComponentFixture, - predicate: () => boolean, - { maxAttempts = 20, intervalMs = 50 }: { maxAttempts?: number; intervalMs?: number } = {} + grid: GridType, + scrollTop: number ): Promise => { - for (let attempt = 0; attempt < maxAttempts && !predicate(); attempt++) { - await new Promise(resolve => setTimeout(resolve, intervalMs)); - fixture.detectChanges(); - await fixture.whenStable(); - } - if (!predicate()) { - throw new Error('waitForGridSettle: condition was not met within the allotted attempts.'); - } -}; - -/** - * Waits until the grid's vertical scroll position stops changing between animation - * frames. Variable-size virtualization corrects its size estimates over several - * render + scroll cycles, which zoneless tests must await instead of forcing - * change detection from grid events. - */ -export const settleGridScroll = async (fixture: ComponentFixture, grid: GridType, maxFrames = 20) => { - let stableFrames = 0; - let previousTop = Number.NaN; - while (maxFrames-- > 0 && stableFrames < 2) { - const currentTop = grid.verticalScrollContainer.getScroll().scrollTop; - stableFrames = currentTop === previousTop ? stableFrames + 1 : 0; - previousTop = currentTop; - await new Promise(resolve => setTimeout(resolve, 32)); - await fixture.whenStable(); - } + const scroll = waitForGridScroll(fixture, grid, 'vertical'); + grid.verticalScrollContainer.getScroll().scrollTop = scrollTop; + await scroll; + fixture.detectChanges(); }; /** From 82469eec2a09863b10db7243178ba493c208d834 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Tue, 14 Jul 2026 14:06:28 +0300 Subject: [PATCH 24/32] fix(grid): update wait for grid events methods for improved navigation handling --- .../grids/grid/src/grid.master-detail.spec.ts | 13 ++++++------- .../test-utils/helper-utils.spec.ts | 6 ++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index f0380b49705..77919dc25f0 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -10,7 +10,7 @@ import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; import { IgxGridExpandableCellComponent } from './expandable-cell.component'; import { GridSummaryPosition, GridSelectionMode, CellType, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; -import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection, waitForGridNavigation } from '../../../test-utils/helper-utils.spec'; import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; import { GridSummaryCalculationMode, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; @@ -674,12 +674,11 @@ describe('IgxGrid Master Detail #grid', () => { const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'CompanyName'); UIInteractions.simulateClickAndSelectEvent(targetCellElement); - const activeNodeChange = waitForActiveNodeChange(grid); - const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); - - await verticalScroll; - await activeNodeChange; + await waitForGridNavigation( + fix, + grid, + () => UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true) + ); const fRow = grid.gridAPI.get_row_by_index(0); expect(fRow).not.toBeUndefined(); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index a026e8326d0..0e5b267a8ce 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -114,7 +114,7 @@ export const navigateWithGridScroll = async ( ); }; -/** Sets vertical scroll position and resolves after the real scroll and chunk events. */ +/** Sets vertical scroll position and resolves after its grid scroll processing completes. */ export const setGridVerticalScrollTop = async ( fixture: ComponentFixture, grid: GridType, @@ -123,7 +123,9 @@ export const setGridVerticalScrollTop = async ( const scroll = waitForGridScroll(fixture, grid, 'vertical'); grid.verticalScrollContainer.getScroll().scrollTop = scrollTop; await scroll; - fixture.detectChanges(); + await fixture.whenStable(); + // Keep consecutive programmatic scrolls in separate browser frames. + await new Promise(resolve => requestAnimationFrame(() => resolve())); }; /** From ab6bccc17518eebe08285ff379fb5f84121ddd04 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Thu, 16 Jul 2026 13:04:27 +0300 Subject: [PATCH 25/32] test(grids): align zone tests with deferred render callbacks --- .../src/app/custom-strategy.spec.ts | 16 +- .../for-of/for_of.directive.spec.ts | 41 +- .../grids/grid/src/column-group.spec.ts | 34 +- .../grids/grid/src/column.spec.ts | 76 +- .../grid/src/grid-cell-selection.spec.ts | 65 +- .../grids/grid/src/grid-filtering-ui.spec.ts | 197 +----- .../grid/src/grid-keyBoardNav-headers.spec.ts | 79 +-- .../grids/grid/src/grid-keyBoardNav.spec.ts | 134 +--- .../grid/src/grid-mrl-keyboard-nav.spec.ts | 236 +------ .../grids/grid/src/grid.component.spec.ts | 35 +- .../grids/grid/src/grid.groupby.spec.ts | 70 +- .../grids/grid/src/grid.master-detail.spec.ts | 12 +- .../grids/grid/src/grid.search.spec.ts | 38 - .../src/hierarchical-grid.navigation.spec.ts | 654 ++---------------- .../hierarchical-grid.virtualization.spec.ts | 141 +--- .../src/tree-grid-keyBoardNav.spec.ts | 490 +------------ .../tree-grid/src/tree-grid-summaries.spec.ts | 95 +-- .../tree-grid/src/tree-grid.component.spec.ts | 37 - .../test-utils/helper-utils.spec.ts | 112 +-- 19 files changed, 232 insertions(+), 2330 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 7d0dc03943a..c3c344d4b6f 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,6 +1,6 @@ import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent } from 'igniteui-angular'; import { html } from 'lit'; -import { filter, firstValueFrom, fromEvent, skip, timer } from 'rxjs'; +import { firstValueFrom, fromEvent, skip, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; import hgridData from '../assets/data/projects-hgrid.js'; import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; @@ -19,12 +19,6 @@ import { defineComponents } from '../utils/register'; describe('Elements: ', () => { let testContainer: HTMLDivElement; - const waitForChildrenResolved = async (element: IgcNgElement, predicate: () => boolean = () => true) => { - if (predicate()) { - return; - } - await firstValueFrom(fromEvent(element, 'childrenResolved').pipe(filter(() => predicate()))); - }; beforeAll(async () =>{ defineComponents( @@ -212,7 +206,7 @@ describe('Elements: ', () => { testContainer.innerHTML = innerHtml; const grid = document.querySelector>('#testGrid'); - await waitForChildrenResolved(grid, () => grid.columns.length === 8); + await firstValueFrom(fromEvent(grid, "childrenResolved")); const thirdGroup = document.querySelector('igc-column-layout[header="Product Stock"]'); const secondGroup = document.querySelector('igc-column-layout[header="Product Details"]'); @@ -221,9 +215,8 @@ describe('Elements: ', () => { expect(grid.getColumnByName('ProductID')).toBeTruthy(); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); - const columnsRemoved = waitForChildrenResolved(grid, () => grid.columns.length === 4); grid.removeChild(secondGroup); - await columnsRemoved; + await firstValueFrom(fromEvent(grid, "childrenResolved")); expect(grid.columns.length).toEqual(4); expect(grid.getColumnByName('ProductID')).toBeTruthy(); @@ -234,9 +227,8 @@ describe('Elements: ', () => { const newColumn = document.createElement('igc-column'); newColumn.setAttribute('field', 'ProductName'); newGroup.appendChild(newColumn); - const columnsInserted = waitForChildrenResolved(grid, () => grid.columns.length === 6); grid.insertBefore(newGroup, thirdGroup); - await columnsInserted; + await firstValueFrom(fromEvent(grid, "childrenResolved")); expect(grid.columns.length).toEqual(6); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index f67f372f1ac..0d8139e0c17 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgClass, NgForOfContext } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, Directive, Injectable, IterableDiffers, NgZone, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, DebugElement, Pipe, PipeTransform, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Directive, Injectable, IterableDiffers, NgZone, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, DebugElement, Pipe, PipeTransform, inject, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -990,45 +990,6 @@ describe('IgxForOf directive -', () => { }); }); - describe('zoneless change detection', () => { - it('should recalculate vertical sizes after scroll without relying on NgZone.onStable', async () => { - TestBed.configureTestingModule({ - imports: [VerticalVirtualComponent], - providers: [provideZonelessChangeDetection()] - }); - const fix = TestBed.createComponent(VerticalVirtualComponent); - dg.generateData(300, 5, fix.componentInstance); - fix.componentRef.hostView.detectChanges(); - fix.detectChanges(); - - const recalcSpy = spyOn(fix.componentInstance.parentVirtDir, 'recalcUpdateSizes').and.callThrough(); - - fix.componentInstance.scrollTop(100); - await fix.whenStable(); - - expect(recalcSpy).toHaveBeenCalled(); - }); - - it('should recalculate horizontal sizes after scroll without relying on NgZone.onStable', async () => { - TestBed.configureTestingModule({ - imports: [HorizontalVirtualComponent], - providers: [provideZonelessChangeDetection()] - }); - const fix = TestBed.createComponent(HorizontalVirtualComponent); - dg.generateData(300, 5, fix.componentInstance); - fix.componentRef.hostView.detectChanges(); - fix.detectChanges(); - - const horizontalDir = fix.componentInstance.childVirtDirs.first; - const recalcSpy = spyOn(horizontalDir, 'recalcUpdateSizes').and.callThrough(); - - fix.componentInstance.scrollLeft(150); - await fix.whenStable(); - - expect(recalcSpy).toHaveBeenCalled(); - }); - }); - describe('variable size component', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts index 647550fd7f3..74c5eb11565 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -1,6 +1,6 @@ import { TestBed, ComponentFixture, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; import { IgxGridComponent } from './grid.component'; -import { DebugElement, QueryList, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement, QueryList } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxColumnComponent } from 'igniteui-angular/grids/core'; import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; @@ -1764,37 +1764,6 @@ describe('IgxGrid - multi-column headers #grid', () => { expect(firstGroupedRow.records.length).toEqual(6); }); }); - describe('Columns widths tests in zoneless change detection (1 group 3 columns) ', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - })); - - it('Width should be correct. Column group with three columns. No width.', async () => { - fixture = TestBed.createComponent(OneGroupThreeColsGridComponent); - fixture.detectChanges(); - await fixture.whenStable(); - await wait(16); - await fixture.whenStable(); - componentInstance = fixture.componentInstance; - grid = fixture.componentInstance.grid; - - const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; - const availableWidth = parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; - const locationColGroup = getColGroup(grid, 'Location'); - const colWidth = availableWidth / 3; - const expectWidthWithinPixel = (actualWidth: string, expectedWidth: number) => - expect(Math.abs(parseFloat(actualWidth) - expectedWidth)).toBeLessThanOrEqual(1); - expectWidthWithinPixel(locationColGroup.width, availableWidth); - const countryColumn = grid.getColumnByName('Country'); - expectWidthWithinPixel(countryColumn.width, colWidth); - const regionColumn = grid.getColumnByName('Region'); - expectWidthWithinPixel(regionColumn.width, colWidth); - const cityColumn = grid.getColumnByName('City'); - expectWidthWithinPixel(cityColumn.width, colWidth); - }); - }); }); const getColGroup = (grid: IgxGridComponent, headerName: string): IgxColumnGroupComponent => { @@ -1940,3 +1909,4 @@ class NestedColGroupsTests { 'slaveColGroup', masterColGroupChildrenCount); } } + diff --git a/projects/igniteui-angular/grids/grid/src/column.spec.ts b/projects/igniteui-angular/grids/grid/src/column.spec.ts index 9a734f50a61..5d875b8fb09 100644 --- a/projects/igniteui-angular/grids/grid/src/column.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column.spec.ts @@ -1,4 +1,4 @@ -import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { getLocaleCurrencySymbol, registerLocaleData } from '@angular/common'; @@ -1162,7 +1162,7 @@ describe('IgxGrid - Column properties #grid', () => { receiveTimeColumn.editorOptions = { dateTimeFormat: 'h-mm-ss aaaaa' }; fix.detectChanges(); - producedDateColumn._cells[0].setEditMode(true); + producedDateColumn._cells[0].setEditMode(true) fix.detectChanges(); tick(); @@ -1174,7 +1174,7 @@ describe('IgxGrid - Column properties #grid', () => { expect((dateTimeEditor.nativeElement as any).value).toEqual('2014-10-01'); - orderDateColumn._cells[0].setEditMode(true); + orderDateColumn._cells[0].setEditMode(true) fix.detectChanges(); tick(); @@ -1186,7 +1186,7 @@ describe('IgxGrid - Column properties #grid', () => { expect((dateTimeEditor.nativeElement as any).value).toEqual('2015--10--01'); - receiveTimeColumn._cells[0].setEditMode(true); + receiveTimeColumn._cells[0].setEditMode(true) fix.detectChanges(); tick(); @@ -1338,7 +1338,7 @@ describe('IgxGrid - Column properties #grid', () => { }; fix.detectChanges(); - producedDateColumn._cells[0].setEditMode(true); + producedDateColumn._cells[0].setEditMode(true) fix.detectChanges(); let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1349,7 +1349,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); - orderDateColumn._cells[0].setEditMode(true); + orderDateColumn._cells[0].setEditMode(true) fix.detectChanges(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1360,7 +1360,7 @@ describe('IgxGrid - Column properties #grid', () => { expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); - receiveTimeColumn._cells[0].setEditMode(true); + receiveTimeColumn._cells[0].setEditMode(true) fix.detectChanges(); inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); @@ -1420,68 +1420,6 @@ describe('IgxGrid - Column properties #grid', () => { }); - describe('Date, DateTime and Time column tests in zoneless change detection', () => { - let grid: IgxGridComponent; - let fix: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - fix = TestBed.createComponent(IgxGridDateTimeColumnComponent); - fix.detectChanges(); - - grid = fix.componentInstance.grid; - }); - - it('Date/Time/DateTime: Use default locale format as inputFormat when editorOptions/pipeArgs formats are null/empty ', async () => { - const producedDateColumn = grid.getColumnByName('ProducedDate'); - const orderDateColumn = grid.getColumnByName('OrderDate'); - const receiveTimeColumn = grid.getColumnByName('ReceiveTime'); - - - producedDateColumn.editorOptions = null; - orderDateColumn.editorOptions.dateTimeFormat = ''; - receiveTimeColumn.pipeArgs = { - format: undefined - }; - await fix.whenStable(); - - producedDateColumn._cells[0].setEditMode(true); - await fix.whenStable(); - - let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); - let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); - dateTimeEditor.nativeElement.focus(); - await wait(16); - await fix.whenStable(); - - expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); - - orderDateColumn._cells[0].setEditMode(true); - await fix.whenStable(); - - inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); - dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); - dateTimeEditor.nativeElement.focus(); - await wait(16); - await fix.whenStable(); - - expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); - - receiveTimeColumn._cells[0].setEditMode(true); - await fix.whenStable(); - - inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); - dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); - dateTimeEditor.nativeElement.focus(); - await wait(16); - await fix.whenStable(); - - expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('08:37 AM'); - }); - }); - describe('Data type image column tests', () => { let fix: ComponentFixture; let grid: IgxGridComponent; diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts index 4738e5d4de8..5be5d94aa71 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -10,11 +10,11 @@ import { IgxGridRowEditingWithoutEditableColumnsComponent } from '../../../test-utils/grid-samples.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridSelectionMode } from 'igniteui-angular/grids/core'; import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { DropPosition } from 'igniteui-angular/grids/core'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; @@ -1448,25 +1448,14 @@ describe('IgxGrid - Cell selection #grid', () => { const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); UIInteractions.simulateClickAndSelectEvent(firstCell); - await wait(); - fix.detectChanges(); - - const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); - const horizontalScroll = waitForGridScroll(fix, grid, 'horizontal'); - - UIInteractions.triggerKeyDownEvtUponElem( - 'end', - firstCell.nativeElement, - true, - false, - true, - true - ); + await fix.whenStable(); - await verticalScroll; - await horizontalScroll; + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); - // Render the final activation/selection state. + UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); + await wait(200); fix.detectChanges(); expect(selectionChangeSpy).toHaveBeenCalledTimes(1); @@ -1747,44 +1736,6 @@ describe('IgxGrid - Cell selection #grid', () => { })); }); - describe('Keyboard navigation in zoneless change detection', () => { - let fix: ComponentFixture; - let grid; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } - ] - }); - fix = TestBed.createComponent(SelectionWithScrollsComponent); - fix.detectChanges(); - grid = fix.componentInstance.grid; - }); - - it('Should handle Shift + Ctrl + End keys combination', (async () => { - const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); - const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); - - UIInteractions.simulateClickAndSelectEvent(firstCell); - await wait(); - await fix.whenStable(); - - expect(selectionChangeSpy).toHaveBeenCalledTimes(0); - GridSelectionFunctions.verifyCellSelected(firstCell); - expect(grid.selectedCells.length).toBe(1); - - UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); - await wait(200); - await fix.whenStable(); - - expect(selectionChangeSpy).toHaveBeenCalledTimes(1); - GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); - GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 2, 5); - })); - }); - describe('Features integration', () => { let fix; let grid; diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts index 001797c31a8..a994f19bc9e 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts @@ -1,4 +1,4 @@ -import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -3213,201 +3213,6 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { closeChipFromFilteringUIRow(fix, grid, 'ReleaseTime', 4); })); }); - - describe('Filtering row UI actions in zoneless change detection', () => { - let fix: ComponentFixture; - let grid: IgxGridComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - { provide: INPUT_DEBOUNCE_TIME, useValue: 0 } - ] - }); - fix = TestBed.createComponent(IgxGridFilteringComponent); - fix.detectChanges(); - grid = fix.componentInstance.grid; - }); - - it('should hide chip arrows when the grid is narrow and column is not filtered', async () => { - grid.width = '400px'; - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // Click string filter chip to show filter row. - GridFunctions.clickFilterCellChip(fix, 'ProductName'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // Verify arrows and chip area are not visible because there is no active filtering for the column. - const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); - const chipArea = filteringRow.query(By.css('igx-chip-area')); - expect(GridFunctions.getFilterRowLeftArrowButton(fix)).toBeNull(); - expect(GridFunctions.getFilterRowRightArrowButton(fix)).toBeNull(); - expect(chipArea).toBeNull('chipArea is present'); - }); - - it('Should navigate from left arrow button to first condition chip Tab.', (async () => { - grid.width = '700px'; - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - GridFunctions.clickFilterCellChip(fix, 'ProductName'); - await fix.whenStable(); - - // Add first chip. - GridFunctions.typeValueInFilterRowInput('a', fix); - await wait(16); - await fix.whenStable(); - GridFunctions.submitFilterRowInput(fix); - await wait(100); - await fix.whenStable(); - // Add second chip. - GridFunctions.typeValueInFilterRowInput('e', fix); - await wait(16); - await fix.whenStable(); - GridFunctions.submitFilterRowInput(fix); - await wait(100); - await fix.whenStable(); - // Add third chip. - GridFunctions.typeValueInFilterRowInput('i', fix); - await wait(16); - await fix.whenStable(); - GridFunctions.submitFilterRowInput(fix); - await wait(100); - await fix.whenStable(); - - // Verify first chip is not in view. - verifyChipVisibility(fix, 0, false); - - const leftArrowButton = GridFunctions.getFilterRowLeftArrowButton(fix).nativeElement; - leftArrowButton.focus(); - await wait(16); - await fix.whenStable(); - leftArrowButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); - await wait(100); - await fix.whenStable(); - - // Verify first chip is in view. - verifyChipVisibility(fix, 0, true); - })); - }); - - describe('Filtering row integration scenarios in zoneless change detection', () => { - let fix: ComponentFixture; - let grid: IgxGridComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - { provide: INPUT_DEBOUNCE_TIME, useValue: 0 } - ] - }); - fix = TestBed.createComponent(IgxGridFilteringComponent); - fix.detectChanges(); - grid = fix.componentInstance.grid; - }); - - it('should display the Row Selector header checkbox above the filter row.', async () => { - grid.rowSelection = GridSelectionMode.multiple; - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - GridFunctions.clickFilterCellChip(fix, 'ProductName'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); - const frElem = filteringRow.nativeElement; - const chkBoxElem = GridSelectionFunctions.getRowCheckboxInput(GridSelectionFunctions.getHeaderRow(fix)); - expect(frElem.offsetTop).toBeGreaterThanOrEqual(chkBoxElem.offsetTop + chkBoxElem.clientHeight); - }); - - it('should display the header expand/collapse icon for groupby above the filter row.', async () => { - grid.getColumnByName('ProductName').groupable = true; - grid.groupBy({ - fieldName: 'ProductName', - dir: SortingDirection.Asc, - ignoreCase: false, - strategy: DefaultSortingStrategy.instance() - }); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - GridFunctions.clickFilterCellChip(fix, 'ProductName'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); - const frElem = filteringRow.nativeElement; - const expandBtn = fix.debugElement.query(By.css('.igx-grid__group-expand-btn')); - const expandBtnElem = expandBtn.nativeElement; - expect(frElem.offsetTop).toBeGreaterThanOrEqual(expandBtnElem.offsetTop + expandBtnElem.clientHeight); - }); - - it('Should display view more indicator when column is resized so not all filters are visible.', async () => { - grid.columnList.get(1).width = '250px'; - await fix.whenStable(); - - // Add initial filtering conditions - const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); - const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); - columnsFilteringTree.filteringOperands = [ - { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, - { fieldName: 'ProductName', searchVal: 'o', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } - ]; - gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); - grid.filteringExpressionsTree = gridFilteringExpressionsTree; - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - let colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); - let colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); - let colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); - - expect(colChips.length).toEqual(2); - expect(colOperands.length).toEqual(1); - expect(colIndicator.length).toEqual(0); - - // Enable resizing - fix.componentInstance.resizable = true; - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // Make 'ProductName' column smaller - const headers: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); - const headerResArea = headers[1].children[2].nativeElement; - UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); - await wait(200); - await fix.whenStable(); - const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; - expect(resizer).toBeDefined(); - UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); - UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); - colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); - colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); - - // The exact number of chips that fit depends on rendered chip metrics, which vary - // slightly across environments; the contract is that not all filters fit anymore - // and the "view more" indicator badge reports the hidden count. - expect(colChips.length).toBeLessThan(2); - expect(colOperands.length).toEqual(Math.max(colChips.length - 1, 0)); - if (colChips.length > 0) { - expect(GridFunctions.getChipText(colChips[0])).toEqual('a'); - } - expect(colIndicator.length).toEqual(1); - - const indicatorBadge = colIndicator[0].query(By.directive(IgxBadgeComponent)); - expect(indicatorBadge).toBeTruthy(); - expect(indicatorBadge.nativeElement.innerText.trim()).toEqual(`${2 - colChips.length}`); - }); - }); }); describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts index 9c426589b28..2e17e4df0e5 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts @@ -1,10 +1,9 @@ import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { provideZonelessChangeDetection } from '@angular/core'; import { IgxGridComponent } from './grid.component'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { SelectionWithScrollsComponent, MRLTestComponent, @@ -63,11 +62,9 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { }); it('should focus first header when the grid is scrolled', async () => { - const verticalScroll = waitForGridScroll(fix, grid, 'vertical'); - const horizontalScroll = waitForGridScroll(fix, grid, 'horizontal'); grid.navigateTo(7, 5); - await verticalScroll; - await horizontalScroll; + await wait(250); + fix.detectChanges(); gridHeader.nativeElement.focus(); //('focus', {}); await wait(250); @@ -480,9 +477,7 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); }); - // fakeAsync's tick does not flush the afterNextRender pass that applies the - // restored header focus/active state; drive it with real timers + whenStable. - it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', async () => { + it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', async () => { // Enable Advanced Filtering grid.allowAdvancedFiltering = true; await fix.whenStable(); @@ -765,72 +760,6 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { }); }); - describe('Headers Navigation in zoneless change detection', () => { - let fix; - let grid: IgxGridComponent; - let gridHeader: IgxGridHeaderRowComponent; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - SelectionWithScrollsComponent, NoopAnimationsModule - ], - providers: [ - provideZonelessChangeDetection(), - IgxGridMRLNavigationService - ] - }).compileComponents(); - })); - - beforeEach(() => { - fix = TestBed.createComponent(SelectionWithScrollsComponent); - fix.detectChanges(); - grid = fix.componentInstance.grid; - gridHeader = GridFunctions.getGridHeader(grid); - }); - - it('should focus first header when the grid is scrolled', async () => { - grid.navigateTo(7, 5); - await wait(250); - await fix.whenStable(); - - gridHeader.nativeElement.focus(); - await wait(250); - await fix.whenStable(); - - const header = GridFunctions.getColumnHeader('ID', fix); - expect(header).not.toBeDefined(); - expect(grid.navigation.activeNode.column).toEqual(3); - expect(grid.navigation.activeNode.row).toEqual(-1); - expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(200); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(100); - }); - - it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', async () => { - grid.allowAdvancedFiltering = true; - await fix.whenStable(); - let header = GridFunctions.getColumnHeader('Name', fix); - UIInteractions.simulateClickAndSelectEvent(header); - await fix.whenStable(); - - GridFunctions.verifyHeaderIsFocused(header.parent); - - UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); - await fix.whenStable(); - - expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); - - const afDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); - UIInteractions.triggerKeyDownEvtUponElem('Escape', afDialog); - await wait(100); - await fix.whenStable(); - - header = GridFunctions.getColumnHeader('Name', fix); - expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); - GridFunctions.verifyHeaderIsFocused(header.parent); - }); - }); - describe('MRL Headers Navigation', () => { let fix; let grid: IgxGridComponent; diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts index 0ba8931c20c..91b3e06a24e 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts @@ -11,7 +11,7 @@ import { } from '../../../test-utils/grid-samples.spec'; import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement, QueryList, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement, QueryList } from '@angular/core'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { CellType } from 'igniteui-angular/grids/core'; import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; @@ -498,13 +498,6 @@ describe('IgxGrid - Keyboard navigation #grid', () => { fix.componentInstance.data = fix.componentInstance.generateData(500); fix.detectChanges(); - grid = fix.componentInstance.grid; - - // Zone.js tests do not auto-flush change detection on NgZone stability inside - // TestBed, and the virtualization scroll pipeline (onScroll -> markForCheck -> - // runAfterRenderOnce) only applies its pending view updates once a CD tick runs. - // An explicit detectChanges() after each real-time wait is required here (unlike - // the zoneless variant below, whose scheduler flushes this automatically). grid.verticalScrollContainer.addScrollTop(5000); await wait(100); fix.detectChanges(); @@ -527,20 +520,9 @@ describe('IgxGrid - Keyboard navigation #grid', () => { await wait(); fix.detectChanges(); - // Ctrl+End targets column 49, which (unlike column 0 for Home) is not in the - // horizontal viewport, so grid.navigateTo() chains a vertical scroll AND a - // horizontal scroll: performVerticalScrollToCell() waits for the vertical - // chunkLoad, then its callback triggers performHorizontalScrollToCell(), which - // waits for a SEPARATE horizontal chunkLoad before activating the target cell. - // The horizontal scrollTo() call only happens once the first detectChanges() - // flushes the vertical chunk load above, so a second real wait + detectChanges - // is needed to let the horizontal scroll's native 'scroll' event fire and its - // own deferred chunkLoad flush before the cell activates. UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement, true, false, false, true); await wait(200); fix.detectChanges(); - await wait(200); - fix.detectChanges(); cell2 = grid.getCellByColumn(499, '49'); GridSelectionFunctions.verifyGridCellSelected(fix, cell2); @@ -721,120 +703,6 @@ describe('IgxGrid - Keyboard navigation #grid', () => { }); }); - describe('in virtualized grid with zoneless change detection', () => { - let fix; - let grid: IgxGridComponent; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - VirtualGridComponent, NoopAnimationsModule - ], - providers: [ - provideZonelessChangeDetection(), - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } - ] - }).compileComponents(); - })); - - beforeEach(() => { - fix = TestBed.createComponent(VirtualGridComponent); - }); - - it('should allow navigating first/last cell in column with home/end and Cntr key.', async () => { - fix.componentInstance.columns = fix.componentInstance.generateCols(50); - fix.componentInstance.data = fix.componentInstance.generateData(500); - fix.detectChanges(); - await fix.whenStable(); - grid = fix.componentInstance.grid; - - grid.verticalScrollContainer.addScrollTop(5000); - await wait(100); - await fix.whenStable(); - - let cell = grid.gridAPI.get_cell_by_index(101, '2'); - UIInteractions.simulateClickAndSelectEvent(cell); - await wait(); - await fix.whenStable(); - - UIInteractions.triggerKeyDownEvtUponElem('home', cell.nativeElement, true, false, false, true); - await wait(150); - await fix.whenStable(); - - let cell2 = grid.getCellByColumn(0, '0'); - GridSelectionFunctions.verifyGridCellSelected(fix, cell2); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(0); - - cell = grid.gridAPI.get_cell_by_index(4, '2'); - UIInteractions.simulateClickAndSelectEvent(cell); - await wait(); - await fix.whenStable(); - - UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement, true, false, false, true); - await wait(200); - await fix.whenStable(); - - cell2 = grid.getCellByColumn(499, '49'); - GridSelectionFunctions.verifyGridCellSelected(fix, cell2); - }); - - it('should allow navigating horizontally virtualized cells with arrow keys.', async () => { - fix.componentInstance.columns = fix.componentInstance.generateCols(50); - fix.componentInstance.data = fix.componentInstance.generateData(500); - fix.detectChanges(); - await fix.whenStable(); - grid = fix.componentInstance.grid; - - let cell = grid.gridAPI.get_cell_by_index(0, '2'); - UIInteractions.simulateClickAndSelectEvent(cell); - await wait(); - await fix.whenStable(); - - UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement); - await wait(150); - await fix.whenStable(); - - let selectedCell = grid.getCellByColumn(0, '3'); - GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); - - cell = grid.gridAPI.get_cell_by_index(0, '3'); - UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement); - await wait(150); - await fix.whenStable(); - - selectedCell = grid.getCellByColumn(0, '2'); - GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); - }); - - it('should allow navigating horizontally virtualized cells with home/end keys.', async () => { - fix.componentInstance.columns = fix.componentInstance.generateCols(50); - fix.componentInstance.data = fix.componentInstance.generateData(500); - fix.detectChanges(); - await fix.whenStable(); - grid = fix.componentInstance.grid; - - let cell = grid.gridAPI.get_cell_by_index(0, '2'); - UIInteractions.simulateClickAndSelectEvent(cell); - await wait(); - await fix.whenStable(); - - UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement); - await wait(150); - await fix.whenStable(); - - let selectedCell = grid.getCellByColumn(0, '49'); - GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); - - cell = grid.gridAPI.get_cell_by_index(0, '49'); - UIInteractions.triggerKeyDownEvtUponElem('home', cell.nativeElement); - await wait(150); - await fix.whenStable(); - - selectedCell = grid.getCellByColumn(0, '0'); - GridSelectionFunctions.verifyGridCellSelected(fix, selectedCell); - }); - }); - describe('Group By navigation ', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index 7f226eff589..ed20e72644b 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -1,19 +1,18 @@ import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; import { By } from '@angular/platform-browser'; +import { firstValueFrom } from 'rxjs'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, navigateWithGridScroll, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; import { CellType, IGridCellEventArgs, IgxColumnComponent, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../src/grid-base.directive'; -import { firstValueFrom } from 'rxjs'; const DEBOUNCE_TIME = 60; const CELL_CSS_CLASS = '.igx-grid__td'; @@ -1582,12 +1581,16 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowLeft', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); @@ -1708,12 +1711,16 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowLeft', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); @@ -1761,13 +1768,19 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { await wait(); fix.detectChanges(); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'End', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'End', false, false, true); + await wait(200); + fix.detectChanges(); + await wait(200); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value) .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'Home', { ctrlKey: true }); + GridFunctions.simulateGridContentKeydown(fix, 'Home', false, false, true); + await wait(200); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); @@ -1930,7 +1943,6 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { ] }]; await wait(DEBOUNCE_TIME); - await fix.whenStable(); fix.detectChanges(); const grid = fix.componentInstance.grid; @@ -2044,7 +2056,9 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight'); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); // check next cell is active cell = grid.getCellByColumn(0, 'City'); @@ -2104,7 +2118,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { // check correct cell is active and is fully in view const lastCell = grid.gridAPI.get_cell_by_index(0, 'Address'); - expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); + expect(lastCell.active).toBe(true); expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(800); let diff = lastCell.nativeElement.getBoundingClientRect().right - grid.tbody.nativeElement.getBoundingClientRect().right; expect(diff).toBe(0); @@ -2116,7 +2130,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { // first cell should be active and is fully in view firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); - expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); + expect(firstCell.active).toBe(true); expect(grid.headerContainer.getScroll().scrollLeft).toBe(0); diff = firstCell.nativeElement.getBoundingClientRect().left - grid.tbody.nativeElement.getBoundingClientRect().left; expect(diff).toBe(0); @@ -2413,13 +2427,16 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { UIInteractions.simulateClickAndSelectEvent(firstCell); fix.detectChanges(); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowRight'); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); expect(secondCell.componentInstance.active).toBeTruthy(); - await navigateWithGridScroll(fix, fix.componentInstance.grid, 'ArrowDown'); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); @@ -2655,195 +2672,6 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { }); }); -describe('IgxGrid Multi Row Layout - Keyboard navigation in zoneless change detection #grid', () => { - let fix: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ColumnLayoutTestComponent], - providers: [ - IgxGridMRLNavigationService, - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }).compileComponents(); - })); - - beforeEach(() => { - fix = TestBed.createComponent(ColumnLayoutTestComponent); - }); - - it(`should navigate to the last cell from the layout by pressing Home/End and Ctrl key - and keep same rowStart from the first selection when last cell spans more rows`, async () => { - fix.componentInstance.colGroups = [{ - group: 'group1', - hidden: true, - columns: [ - { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } - ] - }, { - group: 'group2', - columns: [ - { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, - { field: 'ContactName', rowStart: 2, colStart: 1 }, - { field: 'ContactTitle', rowStart: 2, colStart: 2 }, - { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } - ] - }, { - group: 'group3', - columns: [ - { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, - { field: 'Region', rowStart: 3, colStart: 1 }, - { field: 'PostalCode', rowStart: 3, colStart: 2 } - ] - }, { - group: 'group4', - columns: [ - { field: 'Country', rowStart: 1, colStart: 1 }, - { field: 'Phone', rowStart: 1, colStart: 2 }, - { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } - ] - }]; - await wait(DEBOUNCE_TIME); - fix.detectChanges(); - - // last cell from first layout - const lastCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; - - UIInteractions.simulateClickAndSelectEvent(lastCell); - await wait(); - await fix.whenStable(); - - GridFunctions.simulateGridContentKeydown(fix, 'End', false, false, true); - await wait(200); - await fix.whenStable(); - await wait(200); - await fix.whenStable(); - - expect(fix.componentInstance.selectedCell.value) - .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); - expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); - - GridFunctions.simulateGridContentKeydown(fix, 'Home', false, false, true); - await wait(200); - await fix.whenStable(); - - expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); - expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); - clearGridSubs(); - }); - - it('should scroll active cell fully in view when navigating with arrow keys and row is partially visible.', async () => { - fix.componentInstance.colGroups = [ - { - group: 'group1', - columns: [ - // col span 2 - { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, - { field: 'Phone', rowStart: 2, colStart: 1 }, - { field: 'City', rowStart: 2, colStart: 2 }, - // col span 2 - { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } - ] - }, - { - group: 'group2', - columns: [ - // row span 2 - { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, - { field: 'PostalCode', rowStart: 3, colStart: 1 } - ] - }, - { - group: 'group3', - // row span 3 - columns: [ - { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } - ] - }]; - await wait(DEBOUNCE_TIME); - fix.detectChanges(); - - const grid = fix.componentInstance.grid; - await fix.whenStable(); - - // focus 3rd row, first cell - let cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - // arrow down - GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // check next cell is active and is fully in view - cell = grid.gridAPI.get_cell_by_index(2, 'Phone'); - expect(cell.active).toBe(true); - GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); - let diff = grid.gridAPI.get_cell_by_index(2, 'Phone') - .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; - expect(diff).toBe(0); - - // focus 1st row, 2nd cell - cell = grid.gridAPI.get_cell_by_index(0, 'Phone'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - // arrow up - GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // check next cell is active and is fully in view - cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); - expect(cell.active).toBe(true); - GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); - diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') - .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; - expect(diff).toBe(0); - - // focus 3rd row, first cell - cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - // arrow right - GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // check next cell is active and is fully in view - cell = grid.gridAPI.get_cell_by_index(2, 'Address'); - expect(cell.active).toBe(true); - GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); - diff = grid.gridAPI.get_cell_by_index(2, 'Address') - .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; - expect(diff).toBe(0); - - // focus 1st row, Address - cell = grid.gridAPI.get_cell_by_index(0, 'Address'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - // arrow left - GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); - await wait(DEBOUNCE_TIME); - await fix.whenStable(); - - // check next cell is active and is fully in view - cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); - expect(cell.active).toBe(true); - expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); - diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') - .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; - expect(diff).toBe(0); - }); -}); - @Component({ template: ` diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 45b14362eee..0768f085890 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing'; import { BehaviorSubject, Observable } from 'rxjs'; import { By } from '@angular/platform-browser'; @@ -2105,39 +2105,6 @@ describe('IgxGrid Component Tests #grid', () => { expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); }); - - describe('Zoneless change detection', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - }); - - it('should set correct aria attributes related to total rows/cols count and indexes', async () => { - const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); - fix.componentInstance.initColumnsRows(80, 20); - fix.detectChanges(); - - const grid = fix.componentInstance.grid; - const gridHeader = GridFunctions.getGridHeader(grid); - const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); - - grid.navigateTo(50, 16); - await wait(100); - await fix.whenStable(); - - expect(headerRowElement.getAttribute('aria-rowindex')).toBe('1'); - expect(grid.nativeElement.getAttribute('aria-rowcount')).toBe('81'); - expect(grid.nativeElement.getAttribute('aria-colcount')).toBe('20'); - - const cell = grid.gridAPI.get_cell_by_index(50, 'col16'); - // The following attributes indicate to assistive technologies which portions - // of the content are displayed in case not all are rendered, - // such as with the built-in virtualization of the grid. 1-based index. - expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); - expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); - }); - }); }); describe('IgxGrid - min/max width constraints rules', () => { diff --git a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts index 65422e43a7f..ba85f7020db 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy } from '@angular/core'; import { formatNumber } from '@angular/common' import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -4040,74 +4040,6 @@ describe('IgxGrid - GroupBy #grid', () => { return fix.whenStable(); }; }); - -describe('IgxGrid - GroupBy zoneless change detection #grid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - GroupableGridComponent - ], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - })); - - it('should update horizontal virtualization state correctly when data row views are re-used from cache', async () => { - const fix = TestBed.createComponent(GroupableGridComponent); - const grid = fix.componentInstance.instance; - fix.detectChanges(); - await fix.whenStable(); - - grid.groupBy({ - fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false - }); - await fix.whenStable(); - - grid.toggleAllGroupRows(); - await wait(100); - await fix.whenStable(); - - grid.headerContainer.getScroll().scrollLeft = 1000; - await fix.whenStable(); - - const gridScrLeft = grid.headerContainer.getScroll().scrollLeft; - await wait(100); - await fix.whenStable(); - - grid.toggleAllGroupRows(); - await fix.whenStable(); - await wait(); - - let dataRows = grid.dataRowList.toArray(); - dataRows.forEach(dr => { - const virtualization = dr.virtDirRow; - const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; - expect(virtualization.state.startIndex).toBe(expectedStartIndex); - const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); - expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); - }); - - // scroll down so vertical virtualization recycles row views from cache - grid.verticalScrollContainer.getScroll().scrollTop = 10000; - await wait(100); - await fix.whenStable(); - - dataRows = grid.dataRowList.toArray(); - grid['_restoreVirtState'](dataRows[7]); - await wait(100); - await fix.whenStable(); - - // recycled rows must keep the horizontal virtualization state restored in zoneless mode - dataRows.forEach(dr => { - const virtualization = dr.virtDirRow; - const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; - expect(virtualization.state.startIndex).toBe(expectedStartIndex); - const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); - expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); - }); - }); -}); - @Component({ template: ` { setupGridScrollDetection(fix, grid); grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); await wait(DEBOUNCE_TIME); - await fix.whenStable(); fix.detectChanges(); const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'CompanyName'); UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); - await waitForGridNavigation( - fix, - grid, - () => UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true) - ); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await waitForActiveNodeChange(grid); + fix.detectChanges(); const fRow = grid.gridAPI.get_row_by_index(0); expect(fRow).not.toBeUndefined(); diff --git a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts index a8d222ea42f..4b0341a0416 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; import { By } from '@angular/platform-browser'; import { IgxGridComponent } from './public_api'; import { BasicGridSearchComponent } from '../../../test-utils/grid-base-components.spec'; @@ -1238,43 +1237,6 @@ describe('IgxGrid - search API #grid', () => { }); }); - describe('GroupableGrid in zoneless change detection - ', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - fix = TestBed.createComponent(GroupableGridSearchComponent); - fix.detectChanges(); - component = fix.componentInstance; - grid = component.grid; - fixNativeElement = fix.debugElement.nativeElement; - }); - - it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { - grid.height = '500px'; - await fix.whenStable(); - - grid.groupBy({ - fieldName: 'JobTitle', - dir: SortingDirection.Asc, - ignoreCase: true, - strategy: DefaultSortingStrategy.instance() - }); - await fix.whenStable(); - grid.findNext('a'); - await wait(); - await fix.whenStable(); - - (grid as any).scrollTo(9, 0); - await firstValueFrom(grid.verticalScrollContainer.chunkLoad); - await wait(); - await fix.whenStable(); - const row = grid.gridAPI.get_row_by_index(9); - const spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); - expect(spans.length).toBe(5); - }); - }); - const isInView = (index, state: IForOfState): boolean => index > state.startIndex && index <= state.startIndex + state.chunkSize; const getSpans = () => fixNativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index 77fc7cfd28a..3c27c63de1a 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -1,73 +1,22 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, ViewChild, DebugElement, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { Component, ViewChild, DebugElement, ChangeDetectionStrategy } from '@angular/core'; import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; import { wait, UIInteractions, waitForSelectionChange } from '../../../test-utils/ui-interactions.spec'; import { IgxRowIslandComponent } from './row-island.component'; import { By } from '@angular/platform-browser'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; -import { clearGridSubs, setupHierarchicalGridScrollDetection, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupHierarchicalGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { GridType, IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; import { IPathSegment } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; -import { debounceTime, filter, firstValueFrom, merge, tap } from 'rxjs'; -import { IGridCreatedEventArgs } from './events'; +import { firstValueFrom } from 'rxjs'; const DEBOUNCE_TIME = 60; const GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; const GRID_FOOTER_CLASS = '.igx-grid__tfoot'; -const waitForInitializedGrid = (rowIsland: IgxRowIslandComponent, parentID: any) => - firstValueFrom(rowIsland.gridInitialized.pipe(filter((event: IGridCreatedEventArgs) => event.parentID === parentID))); - -const getRowIslandByKey = (rowIslands, key: string) => - rowIslands.toArray().find((rowIsland: IgxRowIslandComponent) => rowIsland.key === key); - -const expectElementInView = (element: HTMLElement, parent: HTMLElement) => { - const elementRect = element.getBoundingClientRect(); - const parentRect = parent.getBoundingClientRect(); - expect(elementRect.bottom).toBeGreaterThanOrEqual(parentRect.top - 1); - expect(elementRect.top).toBeLessThanOrEqual(parentRect.bottom + 1); -}; - -const runGridAction = async ( - fixture, - grids: GridType[], - action: () => Promise -) => { - const scrollSubscriptions = []; - const gridCreatedSubscriptions = []; - const subscribeToScroll = (grid: GridType) => { - scrollSubscriptions.push(grid.gridScroll.subscribe(() => fixture.detectChanges())); - }; - - grids.forEach((grid) => { - subscribeToScroll(grid); - if (grid instanceof IgxHierarchicalGridComponent) { - grid.allLayoutList.forEach(layout => - gridCreatedSubscriptions.push(layout.gridCreated.subscribe(event => subscribeToScroll(event.grid)))); - } - }); - - try { - await action(); - } finally { - scrollSubscriptions.forEach(subscription => subscription.unsubscribe()); - gridCreatedSubscriptions.forEach(subscription => subscription.unsubscribe()); - } -}; - -const navigateAndWaitForScroll = async (fixture, grids: GridType[], navigate: () => void) => { - const scrollComplete = firstValueFrom(merge(...grids.map(grid => grid.gridScroll)).pipe( - tap(() => fixture.detectChanges()), - debounceTime(DEBOUNCE_TIME) - )); - navigate(); - await scrollComplete; - fixture.detectChanges(); -}; - describe('IgxHierarchicalGrid Navigation', () => { let fixture; let hierarchicalGrid: IgxHierarchicalGridComponent; @@ -207,8 +156,10 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, childCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true)); + UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); + fixture.detectChanges(); + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + // verify selection in child. const selectedCell = fixture.componentInstance.selectedCell; @@ -218,27 +169,27 @@ describe('IgxHierarchicalGrid Navigation', () => { // parent should be scrolled down const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; expect(currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight * 5); + }); it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { - const initialParentScroll = waitForGridScroll(fixture, hierarchicalGrid, 'vertical'); hierarchicalGrid.verticalScrollContainer.scrollTo(2); - await initialParentScroll; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; const horizontalScrDir = childGrid.dataRowList.toArray()[0].virtDirRow; - const initialChildScroll = waitForGridScroll(fixture, childGrid, 'horizontal'); horizontalScrDir.scrollTo(6); - await initialChildScroll; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); - const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'childData2'); + const childLastCell = childGrid.dataRowList.toArray()[9].cells.toArray()[3]; GridFunctions.focusCell(fixture, childLastCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - const parentVerticalScroll = waitForGridScroll(fixture, hierarchicalGrid, 'vertical'); - await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true)); - await parentVerticalScroll; + UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); + await wait(DEBOUNCE_TIME * 3); + fixture.detectChanges(); const selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.value).toEqual(0); @@ -420,8 +371,9 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.dataRowList.first.cells.first; GridFunctions.focusCell(fixture, parentCell); - await navigateAndWaitForScroll(fixture, [hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true)); + UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); + fixture.detectChanges(); + await waitForSelectionChange(hierarchicalGrid); const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); const selectedCell = fixture.componentInstance.selectedCell; @@ -576,8 +528,9 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, fchildRowCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - await navigateAndWaitForScroll(fixture, [child1], () => - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); // second child row should be in view const sChildRowCell = child1.getRowByIndex(2).cells[0]; @@ -586,8 +539,9 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); - await navigateAndWaitForScroll(fixture, [child1], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); selectedCell = fixture.componentInstance.selectedCell; expect(selectedCell.row.index).toBe(0); @@ -731,8 +685,9 @@ describe('IgxHierarchicalGrid Navigation', () => { // navigate up const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - await navigateAndWaitForScroll(fixture, [nestedChild, child, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -746,8 +701,9 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(nextCell.rowIndex).toBe(0); // navigate up into parent - await navigateAndWaitForScroll(fixture, [nestedChild, child, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; @@ -767,8 +723,9 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, nestedChildCell); const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - await navigateAndWaitForScroll(fixture, [nestedChild, child], () => - UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); // check if parent has scrolled down to show focused cell. expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); @@ -797,8 +754,9 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); GridFunctions.focusCell(fixture, parentCell); - await navigateAndWaitForScroll(fixture, [child, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); const nestedChild = child.gridAPI.getChildGrids(false)[5]; const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); @@ -835,8 +793,9 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, child2Cell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - await navigateAndWaitForScroll(fixture, [child2, child1, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); const lastCellPrevRI = child1.dataRowList.last.cells.first; @@ -875,9 +834,11 @@ describe('IgxHierarchicalGrid Navigation', () => { GridFunctions.focusCell(fixture, parentCell); // Arrow Up into prev child grid + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; - await navigateAndWaitForScroll(fixture, [child2, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false)); const child2Cell = child2.dataRowList.last.cells.first; expect(child2Cell.selected).toBe(true); @@ -916,8 +877,9 @@ describe('IgxHierarchicalGrid Navigation', () => { let childLastCell = childGrid.selectedCells; expect(childLastCell.length).toBe(0); - await navigateAndWaitForScroll(fixture, [childGrid2, childGrid, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME * 2); childLastCell = childGrid.selectedCells; expect(childLastCell.length).toBe(1); @@ -965,12 +927,14 @@ describe('IgxHierarchicalGrid Navigation', () => { const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'Col2'); GridFunctions.focusCell(fixture, parentCell); - const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); - const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - await navigateAndWaitForScroll(fixture, [childGrid, hierarchicalGrid], () => - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false)); + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); // last cell in child should be focused + const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'ProductName'); expect(childLastCell.selected).toBe(true); @@ -982,26 +946,17 @@ describe('IgxHierarchicalGrid Navigation', () => { const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - let firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); - if (!firstChildCell) { - const firstChildVerticalScroll = waitForGridScroll(fixture, firstChildGrid, 'vertical'); - const scrollElement = firstChildGrid.verticalScrollContainer.getScroll(); - scrollElement.scrollTop = scrollElement.scrollHeight; - await firstChildVerticalScroll; - firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); - } + firstChildGrid.verticalScrollContainer.scrollTo(9); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); GridFunctions.focusCell(fixture, firstChildCell); const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - await runGridAction(fixture, [firstChildGrid, secondChildGrid, hierarchicalGrid], async () => { - const activeNodeChange = firstValueFrom(secondChildGrid.activeNodeChange); - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - const targetCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); - if (!targetCell?.active) { - await activeNodeChange; - } - fixture.detectChanges(); - }); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); @@ -1037,13 +992,16 @@ describe('IgxHierarchicalGrid Navigation', () => { rowID: 10 }; await wait(16); - await runGridAction(fixture, [hierarchicalGrid], () => - new Promise(resolve => hierarchicalGrid.navigation.navigateToChildGrid([path], resolve))); + hierarchicalGrid.navigation.navigateToChildGrid([path]); + await wait(DEBOUNCE_TIME); fixture.detectChanges(); const childGrid = hierarchicalGrid.gridAPI.getChildGrid([path]).nativeElement; expect(childGrid).not.toBe(undefined); - expectElementInView(childGrid, hierarchicalGrid.tbody.nativeElement); + const parentBottom = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().bottom; + const parentTop = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().top; + // check it's in view within its parent + expect(childGrid.getBoundingClientRect().bottom <= parentBottom && childGrid.getBoundingClientRect().top >= parentTop); }); it('should navigate to exact nested child grid with navigateToChildGrid.', async() => { hierarchicalGrid.expandChildren = false; @@ -1063,480 +1021,18 @@ describe('IgxHierarchicalGrid Navigation', () => { rowID: 5 }; - const rootRowIsland = getRowIslandByKey(hierarchicalGrid.childLayoutList, targetRoot.rowIslandKey); - const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); - const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); - const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); - await runGridAction(fixture, [hierarchicalGrid], async () => { - const navigationComplete = new Promise(resolve => - hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], resolve)); - await childGridInitialized; - await nestedChildGridInitialized; - await navigationComplete; - }); - const childGridComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }]) as IgxHierarchicalGridComponent; - const childGrid = childGridComponent.nativeElement; - expect(childGrid).not.toBe(undefined); - const childGridNestedComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }, { ...targetNested }]) as IgxHierarchicalGridComponent; - const childGridNested = childGridNestedComponent.nativeElement; - expect(childGridNested).not.toBe(undefined); - fixture.detectChanges(); - - expectElementInView(childGridNested, childGrid); - }); - }); - - describe('IgxHierarchicalGrid Basic Navigation in zoneless change detection #hGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [ - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }); - fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); - fixture.detectChanges(); - hierarchicalGrid = fixture.componentInstance.hgrid; - baseHGridContent = GridFunctions.getGridContent(fixture); - GridFunctions.focusFirstCell(fixture, hierarchicalGrid); - })); - - afterEach(() => { - clearGridSubs(); - }); - - it('should allow navigating to end in child grid when child grid target row moves outside the parent view port.', async () => { - const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const childCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; - GridFunctions.focusCell(fixture, childCell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); - await wait(); - await fixture.whenStable(); - - // verify selection in child. - const selectedCell = fixture.componentInstance.selectedCell; - expect(selectedCell.row.index).toEqual(9); - expect(selectedCell.column.field).toMatch('childData2'); - - // parent should be scrolled down - const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; - expect(currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight * 5); - }); - - it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { - hierarchicalGrid.verticalScrollContainer.scrollTo(2); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const horizontalScrDir = childGrid.dataRowList.toArray()[0].virtDirRow; - horizontalScrDir.scrollTo(6); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const childLastCell = childGrid.dataRowList.toArray()[9].cells.toArray()[3]; - GridFunctions.focusCell(fixture, childLastCell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); - await wait(DEBOUNCE_TIME * 3); - await fixture.whenStable(); - - const selectedCell = fixture.componentInstance.selectedCell; - expect(selectedCell.value).toEqual(0); - expect(selectedCell.column.index).toBe(0); - expect(selectedCell.row.index).toBe(0); - - // check if child row is in view of parent. - const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); - const rowElem = childGrid.gridAPI.get_row_by_index(selectedCell.row.index); - const rowOffsets = rowElem.nativeElement.getBoundingClientRect(); - expect(rowOffsets.top).toBeGreaterThanOrEqual(gridOffsets.top); - expect(rowOffsets.bottom).toBeLessThanOrEqual(gridOffsets.bottom); - }); - - it('should move activation to last data cell in grid when ctrl+end is used.', async () => { - const parentCell = hierarchicalGrid.dataRowList.first.cells.first; - GridFunctions.focusCell(fixture, parentCell); - - UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); - await waitForSelectionChange(hierarchicalGrid); - await fixture.whenStable(); - - const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); - const selectedCell = fixture.componentInstance.selectedCell; - expect(selectedCell.row.index).toBe(lastDataCell.row.index); - expect(selectedCell.column.index).toBe(lastDataCell.column.index); - }); - - it('should skip nested child grids that have no data when navigating up/down', async () => { - const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; - child1.height = '150px'; - await wait(); - await fixture.whenStable(); - const row = child1.dataRowList.first as IgxHierarchicalRowComponent; - row.toggle(); - await wait(); - await fixture.whenStable(); - // set nested child to not have data - const subChild = child1.gridAPI.getChildGrids(false)[0]; - subChild.data = []; - await wait(); - await fixture.whenStable(); - - const fchildRowCell = row.cells.first; - GridFunctions.focusCell(fixture, fchildRowCell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - // second child row should be in view - const sChildRowCell = child1.getRowByIndex(2).cells[0]; - let selectedCell = fixture.componentInstance.selectedCell; - expect(selectedCell.value).toBe(sChildRowCell.value); - - expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); - - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - selectedCell = fixture.componentInstance.selectedCell; - expect(selectedCell.row.index).toBe(0); - expect(child1.verticalScrollContainer.getScroll().scrollTop).toBe(0); - }); - }); - - describe('IgxHierarchicalGrid Complex Navigation in zoneless change detection #hGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [ - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }); - fixture = TestBed.createComponent(IgxHierarchicalGridTestComplexComponent); - fixture.detectChanges(); - hierarchicalGrid = fixture.componentInstance.hgrid; - baseHGridContent = GridFunctions.getGridContent(fixture); - GridFunctions.focusFirstCell(fixture, hierarchicalGrid); - })); - - afterEach(() => { - clearGridSubs(); - }); - - it('in case prev cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { - // scroll parent so that child top is not in view - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - hierarchicalGrid.verticalScrollContainer.addScrollTop(300); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const nestedChild = child.gridAPI.getChildGrids(false)[0]; - const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; - - GridFunctions.focusCell(fixture, nestedChildCell); - let oldScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; - await fixture.whenStable(); - - // navigate up - const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; - let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; - const elemHeight = nestedChildCell.intRow.nativeElement.clientHeight; - // check if parent of parent has been scroll up so that the focused cell is in view - expect(oldScrTop - currScrTop).toEqual(elemHeight); - oldScrTop = currScrTop; - - expect(nextCell.selected).toBe(true); - expect(nextCell.active).toBe(true); - expect(nextCell.rowIndex).toBe(0); - - // navigate up into parent - UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; - currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; - expect(oldScrTop - currScrTop).toBeGreaterThanOrEqual(100); - - expect(nextCell.selected).toBe(true); - expect(nextCell.active).toBe(true); - expect(nextCell.rowIndex).toBe(0); - }); - - it('in case next cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { - const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const nestedChild = child.gridAPI.getChildGrids(false)[0]; - const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; - - // navigate down in nested child - GridFunctions.focusCell(fixture, nestedChildCell); - - const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - // check if parent has scrolled down to show focused cell. - expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); - const nextCell = nestedChild.dataRowList.toArray()[2].cells.toArray()[0]; - - expect(nextCell.selected).toBe(true); - expect(nextCell.active).toBe(true); - expect(nextCell.rowIndex).toBe(2); - }); - - it('should allow navigating up from parent into nested child grid', async () => { - hierarchicalGrid.verticalScrollContainer.scrollTo(2); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const lastIndex = child.dataView.length - 1; - child.verticalScrollContainer.scrollTo(lastIndex); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - child.verticalScrollContainer.scrollTo(lastIndex); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); - GridFunctions.focusCell(fixture, parentCell); - - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const nestedChild = child.gridAPI.getChildGrids(false)[5]; - const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); - expect(lastCell.selected).toBe(true); - expect(lastCell.active).toBe(true); - expect(lastCell.row.index).toBe(4); - }); - }); - - describe('IgxHierarchicalGrid sibling row islands Navigation in zoneless change detection #hGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [ - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }); - fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); - fixture.detectChanges(); - hierarchicalGrid = fixture.componentInstance.hgrid; - baseHGridContent = GridFunctions.getGridContent(fixture); - GridFunctions.focusFirstCell(fixture, hierarchicalGrid); - })); - - afterEach(() => { - clearGridSubs(); - }); - - it('should allow navigating up between sibling child grids.', async () => { - hierarchicalGrid.verticalScrollContainer.scrollTo(2); - await wait(); - await fixture.whenStable(); - - const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; - - const child2Cell = child2.dataRowList.first.cells.first; - GridFunctions.focusCell(fixture, child2Cell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const lastCellPrevRI = child1.dataRowList.last.cells.first; - - expect(lastCellPrevRI.active).toBe(true); - expect(lastCellPrevRI.selected).toBe(true); - expect(lastCellPrevRI.rowIndex).toBe(9); - }); - - it('should navigate up from parent row to the correct child sibling.', async () => { - const parentCell = hierarchicalGrid.dataRowList.toArray()[1].cells.first; - GridFunctions.focusCell(fixture, parentCell); - - // Arrow Up into prev child grid - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; - - const child2Cell = child2.dataRowList.last.cells.first; - expect(child2Cell.selected).toBe(true); - expect(child2Cell.active).toBe(true); - expect(child2Cell.rowIndex).toBe(9); - }); - - it('should navigate to last cell in previous child using Arrow Up from last cell of sibling with more columns', async () => { - const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; - - childGrid2.dataRowList.first.virtDirRow.scrollTo(7); - await wait(); - await fixture.whenStable(); - - const child2LastCell = childGrid2.dataRowList.first.cells.first; - GridFunctions.focusCell(fixture, child2LastCell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; - const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; - let childLastCell = childGrid.selectedCells; - expect(childLastCell.length).toBe(0); - - UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); await wait(DEBOUNCE_TIME * 2); - await fixture.whenStable(); - - childLastCell = childGrid.selectedCells; - expect(childLastCell.length).toBe(1); - expect(childLastCell[0].active).toBeTruthy(); - }); - }); - - describe('IgxHierarchicalGrid Smaller Child Navigation in zoneless change detection #hGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [ - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }); - fixture = TestBed.createComponent(IgxHierarchicalGridSmallerChildComponent); - fixture.detectChanges(); - hierarchicalGrid = fixture.componentInstance.hgrid; - baseHGridContent = GridFunctions.getGridContent(fixture); - GridFunctions.focusFirstCell(fixture, hierarchicalGrid); - })); - - afterEach(() => { - clearGridSubs(); - }); - - it('should navigate to last cell in next row for child grid using Arrow Up from last cell of parent with more columns', async () => { - hierarchicalGrid.verticalScrollContainer.scrollTo(2); - await wait(); - await fixture.whenStable(); - - const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'Col2'); - GridFunctions.focusCell(fixture, parentCell); - - UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); - - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - // last cell in child should be focused - const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); - const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'ProductName'); - - expect(childLastCell.selected).toBe(true); - expect(childLastCell.active).toBe(true); - }); - - it('should navigate to last cell in next child using Arrow Down from last cell of previous child with more columns', async () => { - const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); - const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; - - firstChildGrid.verticalScrollContainer.scrollTo(9); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); - GridFunctions.focusCell(fixture, firstChildCell); - - const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; - UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - - - const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); - expect(secondChildCell.selected).toBe(true); - expect(secondChildCell.active).toBe(true); - }); - }); - - describe('IgxHierarchicalGrid Navigation API in zoneless change detection #hGrid', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [ - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }, - provideZonelessChangeDetection() - ] - }); - fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); fixture.detectChanges(); - hierarchicalGrid = fixture.componentInstance.hgrid; - baseHGridContent = GridFunctions.getGridContent(fixture); - GridFunctions.focusFirstCell(fixture, hierarchicalGrid); - })); - - afterEach(() => { - clearGridSubs(); - }); - - it('should navigate to exact nested child grid with navigateToChildGrid.', async() => { - hierarchicalGrid.expandChildren = false; - await wait(DEBOUNCE_TIME); - hierarchicalGrid.primaryKey = 'ID'; - hierarchicalGrid.childLayoutList.toArray()[0].primaryKey = 'ID'; - fixture.detectChanges(); - await wait(DEBOUNCE_TIME); - await fixture.whenStable(); - const targetRoot: IPathSegment = { - rowKey: 10, - rowIslandKey: 'childData', - rowID: 10 - }; - const targetNested: IPathSegment = { - rowKey: 5, - rowIslandKey: 'childData2', - rowID: 5 - }; - - const rootRowIsland = getRowIslandByKey(hierarchicalGrid.childLayoutList, targetRoot.rowIslandKey); - const nestedRowIsland = getRowIslandByKey(rootRowIsland.children, targetNested.rowIslandKey); - const childGridInitialized = waitForInitializedGrid(rootRowIsland, targetRoot.rowID); - const nestedChildGridInitialized = waitForInitializedGrid(nestedRowIsland, targetNested.rowID); - const navigationDone = new Promise(resolve => { - hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], resolve); - }); - await navigationDone; - await fixture.whenStable(); - const initializedChildGrid = (await childGridInitialized).grid; - const childGridComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }]) as IgxHierarchicalGridComponent; - const childGrid = childGridComponent.nativeElement; + const childGrid = hierarchicalGrid.gridAPI.getChildGrid([targetRoot]).nativeElement; expect(childGrid).not.toBe(undefined); - const initializedNestedChildGrid = (await nestedChildGridInitialized).grid; - const childGridNestedComponent = hierarchicalGrid.gridAPI.getChildGrid([{ ...targetRoot }, { ...targetNested }]) as IgxHierarchicalGridComponent; - const childGridNested = childGridNestedComponent.nativeElement; + const childGridNested = hierarchicalGrid.gridAPI.getChildGrid([targetRoot, targetNested]).nativeElement; expect(childGridNested).not.toBe(undefined); - expect(initializedChildGrid).toBe(childGridComponent); - expect(initializedNestedChildGrid).toBe(childGridNestedComponent); - expectElementInView(childGridNested, childGrid); + const parentBottom = childGrid.getBoundingClientRect().bottom; + const parentTop = childGrid.getBoundingClientRect().top; + // check it's in view within its parent + expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); }); }); }); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index 2e14c94b8b9..21baeff788d 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -1,12 +1,12 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, ViewChild, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; import { IgxRowIslandComponent } from './row-island.component'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { By } from '@angular/platform-browser'; import { first, delay } from 'rxjs/operators'; -import { setupHierarchicalGridScrollDetection, clearGridSubs, setGridVerticalScrollTop } from '../../../test-utils/helper-utils.spec'; +import { setupHierarchicalGridScrollDetection, clearGridSubs } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { HierarchicalGridFunctions } from '../../../test-utils/hierarchical-grid-functions.spec'; import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; @@ -202,6 +202,7 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { }); it('should not lose scroll position after expanding a row when there are already expanded rows above.', async () => { + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); // Expand two rows at the top (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); @@ -213,7 +214,9 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { fixture.detectChanges(); // Scroll to bottom - await setGridVerticalScrollTop(fixture, hierarchicalGrid, 5000); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); // Expand two rows at the bottom (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); await wait(); @@ -224,10 +227,14 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { fixture.detectChanges(); // Scroll to top to make sure top. - await setGridVerticalScrollTop(fixture, hierarchicalGrid, 0); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); // Scroll to somewhere in the middle and make sure scroll position stays when expanding/collapsing. - await setGridVerticalScrollTop(fixture, hierarchicalGrid, 1250); + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; const topOffset = hierarchicalGrid.navigation.containerTopOffset; const secondRow = hierarchicalGrid.rowList.toArray()[2]; @@ -251,6 +258,7 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { hierarchicalGrid.navigation.containerTopOffset - topOffset ).toBeLessThanOrEqual(1); + clearGridSubs(); }); it('should be able to scroll last row in view after all rows get expanded.', async () => { @@ -542,126 +550,3 @@ export class IgxHierarchicalGridNoScrollTestComponent extends IgxHierarchicalGri this.data = this.generateData(3, 0); } } - -describe('IgxHierarchicalGrid Virtualization zoneless change detection #hGrid', () => { - let fixture; - let hierarchicalGrid: IgxHierarchicalGridComponent; - let originalTimeout: number; - - beforeEach(() => { - // Scroll-heavy zoneless scenarios settle over many spaced render passes and can - // exceed the default budget on slower machines. - originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000; - }); - - afterEach(() => { - jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; - }); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - IgxHierarchicalGridTestBaseComponent - ], - providers: [ - provideZonelessChangeDetection(), - IgxGridNavigationService, - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } - ] - }).compileComponents(); - })); - - it('should not lose scroll position after expanding a row when there are already expanded rows above', async () => { - fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); - fixture.detectChanges(); - await fixture.whenStable(); - hierarchicalGrid = fixture.componentInstance.hgrid; - - (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - (hierarchicalGrid.dataRowList.toArray()[1].nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; - await chunkLoad; - await fixture.whenStable(); - - (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - (hierarchicalGrid.dataRowList.toArray()[4].nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; - await chunkLoad; - await fixture.whenStable(); - - chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; - await chunkLoad; - await fixture.whenStable(); - - const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; - const topOffset = hierarchicalGrid.navigation.containerTopOffset; - const secondRow = hierarchicalGrid.rowList.toArray()[2]; - - (secondRow.nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); - expect( - hierarchicalGrid.navigation.containerTopOffset - - topOffset - ).toBeLessThanOrEqual(1); - - (secondRow.nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - - expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); - expect( - hierarchicalGrid.navigation.containerTopOffset - - topOffset - ).toBeLessThanOrEqual(1); - }); - - it('should retain expansion state when scrolling', async () => { - fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); - fixture.detectChanges(); - await fixture.whenStable(); - hierarchicalGrid = fixture.componentInstance.hgrid; - - const firstRow = hierarchicalGrid.dataRowList.toArray()[0] as IgxHierarchicalRowComponent; - (firstRow.nativeElement.children[0] as HTMLElement).click(); - await wait(); - await fixture.whenStable(); - expect(firstRow.expanded).toBeTruthy(); - - const verticalScroll = hierarchicalGrid.verticalScrollContainer; - const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; - - // scroll down so the expanded row is recycled out of view - let chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - elem.scrollTop = 1000; - await chunkLoad; - await fixture.whenStable(); - expect(firstRow.expanded).toBeFalsy(); - - // scroll back to top - chunkLoad = firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); - elem.scrollTop = 0; - await chunkLoad; - await fixture.whenStable(); - expect(firstRow.expanded).toBeTruthy(); - }); -}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 66276d15740..1fc344f8240 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -4,9 +4,9 @@ import { IgxTreeGridComponent } from './public_api'; import { IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { clearGridSubs, setupGridScrollDetection, waitForGridNavigation, waitForGridScroll } from '../../../test-utils/helper-utils.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { CellType } from 'igniteui-angular/grids/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; @@ -423,7 +423,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 5; i < 9; i++) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); - await wait(DEBOUNCETIME); + await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); fix.detectChanges(); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); @@ -433,7 +433,10 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { for (let i = 9; i > 0; i--) { let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); - await wait(DEBOUNCETIME); + if (i <= 4) + await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); + else + await wait(); fix.detectChanges(); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); @@ -562,21 +565,17 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - let verticalScroll = waitForGridScroll(fix, treeGrid, 'vertical'); - let horizontalScroll = waitForGridScroll(fix, treeGrid, 'horizontal'); UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); - await verticalScroll; - await horizontalScroll; + await wait(100); + fix.detectChanges(); cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); - verticalScroll = waitForGridScroll(fix, treeGrid, 'vertical'); - horizontalScroll = waitForGridScroll(fix, treeGrid, 'horizontal'); UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); - await verticalScroll; - await horizontalScroll; + await wait(100); + fix.detectChanges(); cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); @@ -667,22 +666,18 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { } let newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); - await waitForGridNavigation( - fix, - treeGrid, - () => UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent) - ); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); expect(newCell.editMode).toBe(true); expect( treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); - await waitForGridNavigation( - fix, - treeGrid, - () => UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true) - ); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); expect(newCell.editMode).toBe(true); @@ -851,455 +846,4 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); }); }); - - describe('Navigation with scrolls in zoneless change detection', () => { - let fix; - let treeGrid: IgxTreeGridComponent; - let gridContent: DebugElement; - const treeColumns = ['ID', 'Name', 'HireDate', 'Age', 'OnPTO']; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } - ] - }); - fix = TestBed.createComponent(IgxTreeGridWithScrollsComponent); - fix.detectChanges(); - treeGrid = fix.componentInstance.treeGrid; - gridContent = GridFunctions.getGridContent(fix); - }); - - it('should navigate with arrow Up and Down keys', async () => { - spyOn(treeGrid.selected, 'emit').and.callThrough(); - const firstCell: CellType = treeGrid.gridAPI.get_cell_by_index(5, 'ID'); - UIInteractions.simulateClickAndSelectEvent(firstCell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, treeGrid.gridAPI.get_cell_by_index(5, 'ID')); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - - for (let i = 5; i < 9; i++) { - let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); - const chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); - await chunkLoad; - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - - for (let i = 9; i > 0; i--) { - let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); - let chunkLoad: Promise | null = null; - if (i <= 4) { - chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); - } - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); - if (chunkLoad) { - await chunkLoad; - } else { - await wait(); - } - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(14); - }); - - it('should navigate with arrow Left and Right', async () => { - const firstCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); - spyOn(treeGrid.selected, 'emit').and.callThrough(); - - UIInteractions.simulateClickAndSelectEvent(firstCell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - - for (let i = 0; i < treeColumns.length - 1; i++) { - let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i + 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(i + 2); - } - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); - await wait(); - await fix.whenStable(); - - let lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); - - for (let i = treeColumns.length - 1; i > 0; i--) { - let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i - 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - i); - } - - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); - await wait(); - await fix.whenStable(); - - lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - 1); - }); - - it('should move to the top/bottom cell when navigate with Ctrl + arrow Up/Down', async () => { - spyOn(treeGrid.selected, 'emit').and.callThrough(); - let cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); - - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); - await wait(100); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(9, 'Name'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); - - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); - await wait(100); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(0, 'Name'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); - }); - - it('should move to the top left/bottom right cell when navigate with Ctrl + Home/End keys', async () => { - spyOn(treeGrid.selected, 'emit').and.callThrough(); - let cell = treeGrid.gridAPI.get_cell_by_index(2, treeColumns[2]); - - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - - UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); - await wait(100); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); - - UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); - await wait(100); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); - }); - - it('should allow pageup/pagedown navigation when the treeGrid is focused', async () => { - const cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - UIInteractions.triggerEventHandlerKeyDown('PageDown', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - expect(treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(100); - - UIInteractions.triggerEventHandlerKeyDown('PageUp', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - expect(treeGrid.headerContainer.getScroll().scrollTop).toEqual(0); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - }); - - it('should change editable cell and scroll when Tab and Shift + Tab keys are pressed', async () => { - treeGrid.getColumnByName('ID').editable = true; - treeGrid.getColumnByName('Name').editable = true; - treeGrid.getColumnByName('HireDate').editable = true; - treeGrid.getColumnByName('Age').editable = true; - treeGrid.getColumnByName('OnPTO').editable = true; - await fix.whenStable(); - - const firstCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[2]); - UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); - expect(firstCell.editMode).toBe(true); - - for (let i = 2; i < treeColumns.length - 1; i++) { - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - const cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i + 1]); - expect(cell.editMode).toBe(true); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); - await wait(DEBOUNCETIME * 2); - await fix.whenStable(); - - let newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); - expect(newCell.editMode).toBe(true); - expect(treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); - - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); - await wait(DEBOUNCETIME * 2); - await fix.whenStable(); - - newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); - expect(newCell.editMode).toBe(true); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); - - for (let i = 4; i > 0; i--) { - let cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i]); - UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i - 1]); - expect(cell.editMode).toBe(true); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - }); - - it('should navigate with arrow Left key when there is a pinned column', async () => { - treeGrid.getColumnByName('HireDate').pinned = true; - await fix.whenStable(); - - const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; - const firstCell = treeGrid.gridAPI.get_cell_by_index(3, 'HireDate'); - - UIInteractions.simulateClickAndSelectEvent(firstCell); - await fix.whenStable(); - - UIInteractions.triggerEventHandlerKeyDown('End', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - const lastCell = treeGrid.gridAPI.get_cell_by_index(3, columns[4]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); - expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); - - for (let i = 4; i > 0 ; i--) { - let cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i]); - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i - 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - - expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(0); - }); - - it('should navigate with arrow Right key when there is a pinned column', async () => { - treeGrid.getColumnByName('HireDate').pinned = true; - await fix.whenStable(); - - const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; - const firstCell = treeGrid.gridAPI.get_cell_by_index(0, 'HireDate'); - - UIInteractions.simulateClickAndSelectEvent(firstCell); - await fix.whenStable(); - - UIInteractions.triggerEventHandlerKeyDown('End', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - let newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[4]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); - const scrollLeft = treeGrid.headerContainer.getScroll().scrollLeft; - expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); - - UIInteractions.simulateClickAndSelectEvent(firstCell); - await fix.whenStable(); - - for (let i = 0; i < columns.length - 1; i++) { - let cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i]); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); - cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i + 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - } - - UIInteractions.triggerEventHandlerKeyDown('Home', gridContent); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); - expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(scrollLeft); - }); - - it('should move to the leftmost/rightmost cell when navigate with Ctrl + arrow Left/Right keys', async () => { - spyOn(treeGrid.selected, 'emit').and.callThrough(); - let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[1]); - - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); - - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, false, false, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - await wait(DEBOUNCETIME); - await fix.whenStable(); - - expect(treeGrid.selected.emit).toHaveBeenCalledTimes(4); - }); - - it('should expand/collapse row when Alt + arrow Left/Right keys are pressed', async () => { - treeGrid.width = '400px'; - await wait(DEBOUNCETIME); - await fix.whenStable(); - treeGrid.headerContainer.scrollTo(4); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - const cell = treeGrid.gridAPI.get_cell_by_index(3, 'OnPTO'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - let rows = TreeGridFunctions.getAllRows(fix); - expect(rows.length).toBe(7); - TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[3]); - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - rows = TreeGridFunctions.getAllRows(fix); - expect(rows.length).toBe(8); - TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[3]); - }); - - it('should select correct cells after expand/collapse row', async () => { - let rows; - let cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); - UIInteractions.simulateClickAndSelectEvent(cell); - await fix.whenStable(); - - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - rows = TreeGridFunctions.getAllRows(fix); - expect(rows.length).toBe(4); - TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); - - TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); - - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); - - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - rows = TreeGridFunctions.getAllRows(fix); - cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); - expect(rows.length).toBe(8); - TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); - - TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); - - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); - - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); - - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - cell = treeGrid.gridAPI.get_cell_by_index(9, 'ID'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - TreeGridFunctions.moveCellUpDown(fix, treeGrid, 9, 'ID', false); - - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - rows = TreeGridFunctions.getAllRows(fix); - expect(rows.length).toBe(8); - TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[7]); - cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'ID', 'Name', true); - TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'Name', 'ID', false); - - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); - await wait(DEBOUNCETIME); - await fix.whenStable(); - - rows = TreeGridFunctions.getAllRows(fix); - expect(rows.length).toBe(8); - TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[6]); - cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); - TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); - }); - }); }); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index b34d966bc86..bc4762c7640 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -11,19 +11,14 @@ import { import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { IgxTreeGridComponent } from './tree-grid.component'; import { IgxSummaryRow, IgxTreeGridRow } from 'igniteui-angular/grids/core'; import { IgxNumberFilteringOperand } from 'igniteui-angular/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; -import { firstValueFrom } from 'rxjs'; describe('IgxTreeGrid - Summaries #tGrid', () => { const DEBOUNCETIME = 30; - const childSummaryRowIndexes = [6, 7, 12, 13, 16, 22, 24]; - const getChildSummaryRowIndexes = (treeGrid: IgxTreeGridComponent) => treeGrid.dataView - .map((record, index) => treeGrid.isSummaryRow(record) ? index : -1) - .filter(index => index !== -1); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -170,29 +165,35 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { fix.detectChanges(); verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + let rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); treeGrid.summaryCalculationMode = 'rootLevelOnly'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual([]); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); treeGrid.summaryCalculationMode = 'childLevelsOnly'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); expect(summaryRow).toBeNull(); treeGrid.summaryCalculationMode = 'rootAndChildLevels'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); }); it('should be able to show/hide summaries for collapsed parent rows runtime', () => { @@ -865,54 +866,6 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { }); }); - describe('Runtime summary settings in zoneless change detection', () => { - let fix; - let treeGrid: IgxTreeGridComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - fix = TestBed.createComponent(IgxTreeGridSummariesKeyComponent); - fix.detectChanges(); - treeGrid = fix.componentInstance.treeGrid; - }); - - afterEach(() => { - clearGridSubs(); - }); - - it('should be able to change summaryCalculationMode at runtime', async () => { - treeGrid.expandAll(); - await fix.whenStable(); - - verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); - - treeGrid.summaryCalculationMode = 'rootLevelOnly'; - await wait(50); - await fix.whenStable(); - - verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual([]); - - treeGrid.summaryCalculationMode = 'childLevelsOnly'; - await wait(50); - await fix.whenStable(); - - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); - const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); - expect(summaryRow).toBeNull(); - - treeGrid.summaryCalculationMode = 'rootAndChildLevels'; - await wait(50); - await fix.whenStable(); - - verifyTreeBaseSummaries(fix); - expect(getChildSummaryRowIndexes(treeGrid)).toEqual(childSummaryRowIndexes); - }); - }); - describe('CRUD with transactions', () => { let fix; let treeGrid; @@ -1755,23 +1708,29 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { it('should render rows correctly after collapse and expand', async () => { const fix = TestBed.createComponent(IgxTreeGridSummariesScrollingComponent); const treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + fix.detectChanges(); + await wait(30); fix.detectChanges(); - await fix.whenStable(); - - const chunkLoad = firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); (treeGrid as any).scrollTo(23, 0, 0); - await chunkLoad; - await fix.whenStable(); + fix.detectChanges(); + await wait(60); + fix.detectChanges(); let row = treeGrid.getRowByKey(15); row.expanded = false; - await fix.whenStable(); + fix.detectChanges(); + await wait(30); + fix.detectChanges(); row = treeGrid.getRowByKey(15); row.expanded = true; - await fix.whenStable(); + fix.detectChanges(); + await wait(30); + fix.detectChanges(); expect(treeGrid.dataRowList.length).toEqual(10); + clearGridSubs(); }); const verifySummaryForRow147 = (fixture, visibleIndex) => { diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts index 9a053743ecd..74eeb4c401f 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts @@ -1,5 +1,4 @@ import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxTreeGridComponent } from './tree-grid.component'; import { By } from '@angular/platform-browser'; @@ -298,42 +297,6 @@ describe('IgxTreeGrid Component Tests #tGrid', () => { })); }); - describe('Auto-generated columns in zoneless change detection', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - providers: [provideZonelessChangeDetection()] - }); - fix = TestBed.createComponent(IgxTreeGridComponent); - grid = fix.componentInstance; - grid.autoGenerate = true; - - // When doing pure unit tests, the grid doesn't get removed after the test, because it overrides - // the element ID and the testbed cannot find it to remove it. - // The testbed is looking up components by [id^=root], so working around this by forcing root id - grid.id = SAFE_DISPOSE_COMP_ID; - fix.detectChanges(); - })); - - it('should auto-generate all columns', async () => { - grid.data = []; - await fix.whenStable(); - - grid.data = SampleTestData.employeePrimaryForeignKeyTreeData(); - await fix.whenStable(); - - grid.primaryKey = 'ID'; - grid.foreignKey = 'ParentID'; - await wait(100); - await fix.whenStable(); - - const expectedColumns = [...Object.keys(grid.data[0])]; - - expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); - // Verify that records are also rendered by checking the first record cell - expect(grid.getCellByColumn(0, 'ID').value).toEqual(1); - }); - }); - describe('Loading Template', () => { beforeEach(waitForAsync(() => { fix = TestBed.createComponent(IgxTreeGridDefaultLoadingComponent); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index 0e5b267a8ce..b7cac42522c 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -1,9 +1,8 @@ import { EventEmitter, NgZone, Injectable } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; -import { GridType, IGridScrollEventArgs } from 'igniteui-angular/grids/core'; +import { GridType } from 'igniteui-angular/grids/core'; import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; -import { filter, firstValueFrom, Subscription } from 'rxjs'; -import { GridFunctions } from './grid-functions.spec'; +import { Subscription } from 'rxjs'; /** * Global beforeEach and afterEach checks to ensure test fails on specific warnings @@ -26,7 +25,38 @@ import { GridFunctions } from './grid-functions.spec'; export let gridsubscriptions: Subscription [] = []; +/** + * Coalesces change-detection requests into a single deferred pass. `NgZone.onStable` + * used to run the deferred grid callbacks at zone quiescence — after the scroll handler + * and its synchronous size corrections had completed. Running change detection + * synchronously from inside the scroll event would render mid-correction states instead. + */ +const scheduleDetectChanges = (detectChanges: () => void, isDestroyed: () => boolean) => { + let scheduled = false; + return () => { + if (scheduled) { + return; + } + scheduled = true; + setTimeout(() => { + scheduled = false; + if (!isDestroyed()) { + detectChanges(); + } + }); + }; +}; + export const setupGridScrollDetection = (fixture: ComponentFixture, grid: GridType) => { + // gridScroll is emitted synchronously from the scroll handler, upstream of the + // deferred (afterNextRender) chunkLoad emit, so this subscription bootstraps the + // render pass those deferred callbacks need — the manual-CD stand-in for the tick + // the framework scheduler guarantees in real applications. + const scheduleFixtureDetectChanges = scheduleDetectChanges( + () => fixture.detectChanges(), + () => fixture.componentRef.hostView.destroyed + ); + gridsubscriptions.push(grid.gridScroll.subscribe(scheduleFixtureDetectChanges)); gridsubscriptions.push(grid.verticalScrollContainer.chunkLoad.subscribe(() => fixture.detectChanges())); gridsubscriptions.push(grid.parentVirtDir.chunkLoad.subscribe(() => fixture.detectChanges())); gridsubscriptions.push(grid.activeNodeChange.subscribe(() => grid.cdr.detectChanges())); @@ -52,82 +82,6 @@ export const clearGridSubs = () => { gridsubscriptions = []; } -export type GridScrollDirection = 'vertical' | 'horizontal'; - -/** - * Resolves after a real grid scroll event and its deferred chunk render complete. - * Call this before the interaction that causes the scroll so neither event is missed. - * Existing ZoneJS fixtures require one explicit render between those boundaries. - */ -export const waitForGridScroll = async ( - fixture: ComponentFixture, - grid: GridType, - direction: GridScrollDirection -): Promise => { - const scrollEvent = firstValueFrom(grid.gridScroll.pipe( - filter((event: IGridScrollEventArgs) => event.direction === direction) - )); - const chunkLoad = firstValueFrom( - direction === 'vertical' ? grid.verticalScrollContainer.chunkLoad : grid.parentVirtDir.chunkLoad - ); - - await scrollEvent; - fixture.detectChanges(); - await chunkLoad; -}; - -/** - * Runs a grid navigation and resolves once the resulting `activeNodeChange` has fired. - * - * The `activeNodeChange` subscription is created before the keydown so a synchronous emit - * is not missed. Real scroll events drive the fixture render needed for deferred - * post-render work without requiring the test to predict whether or which axis scrolls. - */ -export const waitForGridNavigation = async ( - fixture: ComponentFixture, - grid: GridType, - navigate: () => void -): Promise => { - const scrollSubscription = grid.gridScroll.subscribe(() => fixture.detectChanges()); - const activeNodeChange = firstValueFrom(grid.activeNodeChange); - - try { - navigate(); - await activeNodeChange; - fixture.detectChanges(); - } finally { - scrollSubscription.unsubscribe(); - } -}; - -/** Simulates keyboard navigation and waits for its final active-node update. */ -export const navigateWithGridScroll = async ( - fixture: ComponentFixture, - grid: GridType, - key: string, - { ctrlKey = false }: { ctrlKey?: boolean } = {} -): Promise => { - await waitForGridNavigation( - fixture, - grid, - () => GridFunctions.simulateGridContentKeydown(fixture, key, false, false, ctrlKey) - ); -}; - -/** Sets vertical scroll position and resolves after its grid scroll processing completes. */ -export const setGridVerticalScrollTop = async ( - fixture: ComponentFixture, - grid: GridType, - scrollTop: number -): Promise => { - const scroll = waitForGridScroll(fixture, grid, 'vertical'); - grid.verticalScrollContainer.getScroll().scrollTop = scrollTop; - await scroll; - await fixture.whenStable(); - // Keep consecutive programmatic scrolls in separate browser frames. - await new Promise(resolve => requestAnimationFrame(() => resolve())); -}; - /** * Sets element size as a inline style */ From 001d30684cf957c30ae92e53694024ae841cf9d8 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 17 Jul 2026 11:53:13 +0300 Subject: [PATCH 26/32] test(grids): add zoneless regression coverage --- .../grids/grid/src/column.spec.ts | 29 +++++- .../grid/src/grid-cell-selection.spec.ts | 41 +++++++- .../grids/grid/src/grid.component.spec.ts | 97 ++++++++++++++++++- .../grids/grid/src/grid.groupby.spec.ts | 48 ++++++++- .../src/hierarchical-grid.navigation.spec.ts | 34 ++++++- .../src/tree-grid-integration.spec.ts | 74 +++++++++++++- .../src/tree-grid-keyBoardNav.spec.ts | 51 +++++++++- .../test-utils/tree-grid-components.spec.ts | 31 ++++++ 8 files changed, 395 insertions(+), 10 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/column.spec.ts b/projects/igniteui-angular/grids/grid/src/column.spec.ts index 5d875b8fb09..fd73bc8c071 100644 --- a/projects/igniteui-angular/grids/grid/src/column.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column.spec.ts @@ -1,4 +1,4 @@ -import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { Component, DebugElement, TemplateRef, ViewChild, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { getLocaleCurrencySymbol, registerLocaleData } from '@angular/common'; @@ -1942,3 +1942,30 @@ export class DOMAttributesAsSettersComponent { public data = [{ id: 1, value: 1 }]; } + +describe('IgxGrid column autosizing in zoneless change detection #grid', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ResizableColumnsComponent, NoopAnimationsModule], + providers: [provideZonelessChangeDetection()] + }); + }); + + it('should recalculate fit-content widths after data changes', async () => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.instance; + + grid.data = [{ + ID: 'VeryVeryVeryLongID', + Address: 'Avda. de la Constituci\u00f3n 2222 Obere Str. 57' + }]; + await fix.whenStable(); + grid.recalculateAutoSizes(); + await fix.whenStable(); + + expect(grid.columns[0].width).toBe('164px'); + expect(grid.columns[1].width).toBe('279px'); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts index 5be5d94aa71..f16d7153300 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -14,7 +14,8 @@ import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/hel import { GridSelectionMode } from 'igniteui-angular/grids/core'; import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; import { DropPosition } from 'igniteui-angular/grids/core'; import { IgxGridGroupByRowComponent } from './groupby-row.component'; import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; @@ -1736,6 +1737,44 @@ describe('IgxGrid - Cell selection #grid', () => { })); }); + describe('Keyboard navigation in zoneless change detection', () => { + let fix: ComponentFixture; + let grid; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Should handle Shift + Ctrl + End keys combination', async () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await fix.whenStable(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + const rangeSelected = firstValueFrom(grid.rangeSelected); + UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); + await rangeSelected; + await fix.whenStable(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 2, 5); + }); + }); + describe('Features integration', () => { let fix; let grid; diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 0768f085890..f8ce0013662 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; @@ -2105,6 +2105,63 @@ describe('IgxGrid Component Tests #grid', () => { expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); }); + + describe('Zoneless rendering regressions', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ZonelessTallGridComponent, ZonelessFinJsGridComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + }); + + it('should fully display the last row after scrolling to the bottom', async () => { + const fix = TestBed.createComponent(ZonelessTallGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); + + grid.verticalScrollContainer.scrollTo(fix.componentInstance.data.length - 1); + await chunkLoad; + await fix.whenStable(); + + const lastRow = grid.gridAPI.get_row_by_index(fix.componentInstance.data.length - 1); + const rowRect = lastRow.nativeElement.getBoundingClientRect(); + const viewportRect = grid.tbody.nativeElement.getBoundingClientRect(); + expect(rowRect.bottom).toBeLessThanOrEqual(viewportRect.bottom + 1); + expect(Math.abs(viewportRect.bottom - rowRect.bottom)).toBeLessThanOrEqual(1); + }); + + it('should stabilize aria-colcount when grouped columns are hidden', async () => { + const fix = TestBed.createComponent(ZonelessFinJsGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + expect(grid.nativeElement.getAttribute('aria-colcount')).toBe('48'); + expect(grid.columns.length).toBe(51); + expect(grid.visibleColumns.length).toBe(48); + }); + + it('should update horizontal virtualization after a real scroll event', async () => { + const fix = TestBed.createComponent(ZonelessFinJsGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + const chunkLoad = firstValueFrom(grid.parentVirtDir.chunkLoad); + const horizontalScroller = grid.headerContainer.getScroll(); + + horizontalScroller.scrollLeft = horizontalScroller.scrollWidth; + horizontalScroller.dispatchEvent(new Event('scroll')); + await chunkLoad; + await fix.whenStable(); + + expect(grid.headerContainer.state.startIndex).toBeGreaterThan(0); + }); + }); }); describe('IgxGrid - min/max width constraints rules', () => { @@ -3461,6 +3518,42 @@ export class IgxGridDefaultRenderingComponent { } } +@Component({ + template: ``, + imports: [IgxGridComponent] +}) +class ZonelessTallGridComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + public data = Array.from({ length: 200 }, (_row, index) => ({ + ID: index, + Name: `Record ${index}`, + Value: index * 10 + })); +} + +@Component({ + template: ` + + @for (column of columns; track column) { + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessFinJsGridComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + public columns = Array.from({ length: 51 }, (_column, index) => `Column${index}`); + public groupingExpressions: ISortingExpression[] = this.columns.slice(0, 3).map(fieldName => ({ + fieldName, + dir: SortingDirection.Asc, + ignoreCase: true + })); + public data = Array.from({ length: 1000 }, (_row, rowIndex) => + Object.fromEntries(this.columns.map((column, columnIndex) => [column, `${rowIndex}-${columnIndex}`]))); +} + @Component({ template: `
diff --git a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts index ba85f7020db..6d1ebd6ae71 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, TemplateRef, QueryList, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { formatNumber } from '@angular/common' import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -20,6 +20,7 @@ import { DefaultSortingStrategy, IGroupingExpression, IgxGrouping, IgxStringFilt import { IgxChipComponent } from 'igniteui-angular/chips'; import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { firstValueFrom } from 'rxjs'; describe('IgxGrid - GroupBy #grid', () => { @@ -4392,3 +4393,48 @@ export class GridGroupByStateComponent extends GridGroupByTestDateTimeDataCompon @ViewChild(IgxGridStateDirective, { static: true }) public state: IgxGridStateDirective; } + +describe('IgxGrid grouped virtualization in zoneless change detection #grid', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [GroupableGridComponent, NoopAnimationsModule], + providers: [provideZonelessChangeDetection()] + }); + }); + + it('should restore horizontal state when data row views are reused from cache', async () => { + const fix = TestBed.createComponent(GroupableGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.instance; + + grid.groupBy({ fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false }); + await fix.whenStable(); + grid.toggleAllGroupRows(); + await fix.whenStable(); + + const horizontalChunkLoad = firstValueFrom(grid.parentVirtDir.chunkLoad); + const horizontalScroller = grid.headerContainer.getScroll(); + horizontalScroller.scrollLeft = 1000; + horizontalScroller.dispatchEvent(new Event('scroll')); + await horizontalChunkLoad; + await fix.whenStable(); + + const scrollLeft = horizontalScroller.scrollLeft; + grid.toggleAllGroupRows(); + await fix.whenStable(); + + const verticalChunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await verticalChunkLoad; + await fix.whenStable(); + + for (const row of grid.dataRowList) { + const virtualization = row.virtDirRow; + const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; + const left = parseFloat(virtualization.dc.instance._viewContainer.element.nativeElement.style.left); + expect(virtualization.state.startIndex).toBe(expectedStartIndex); + expect(-left).toBe(scrollLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); + } + }); +}); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index 3c27c63de1a..7ef755f3f29 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -1,6 +1,6 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, ViewChild, DebugElement, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, DebugElement, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; import { wait, UIInteractions, waitForSelectionChange } from '../../../test-utils/ui-interactions.spec'; import { IgxRowIslandComponent } from './row-island.component'; @@ -1035,6 +1035,38 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); }); }); + describe('IgxHierarchicalGrid Basic Navigation in zoneless change detection #hGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + })); + + it('should activate the target cell after Ctrl + End scrolls a child grid', async () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; + GridFunctions.focusCell(fixture, childCell); + await fixture.whenStable(); + + const activeNodeChange = firstValueFrom(childGrid.activeNodeChange); + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); + await activeNodeChange; + await fixture.whenStable(); + + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toEqual(9); + expect(selectedCell.column.field).toMatch('childData2'); + expect(hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop) + .toBeGreaterThanOrEqual(childGrid.rowHeight * 5); + }); + }); }); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts index 0127421aaad..294347c8560 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts @@ -1,4 +1,5 @@ import { TestBed, ComponentFixture, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; +import { Component, provideZonelessChangeDetection, ViewChild } from '@angular/core'; import { IgxTreeGridComponent } from './tree-grid.component'; import { IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent, @@ -12,10 +13,11 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; import { By } from '@angular/platform-browser'; -import { CellType, DropPosition, IgxTreeGridRow } from 'igniteui-angular/grids/core'; +import { CellType, DropPosition, IgxColumnComponent, IgxTreeGridRow } from 'igniteui-angular/grids/core'; import { IgxTreeGridRowComponent } from './tree-grid-row.component'; import { IgxGridTransaction } from 'igniteui-angular/grids/core'; import { HierarchicalTransaction, IgxHierarchicalTransactionService, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection, TransactionType } from 'igniteui-angular/core'; +import { firstValueFrom } from 'rxjs'; const CSS_CLASS_BANNER = 'igx-banner'; const CSS_CLASS_ROW_EDITED = 'igx-grid__tr--edited'; @@ -1828,4 +1830,74 @@ describe('IgxTreeGrid - Integration #tGrid', () => { expect(firstRow.isRoot).toBe(false); }); }); + + describe('Column autosizing in zoneless change detection', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TreeGridZonelessAutosizeComponent], + providers: [provideZonelessChangeDetection()] + }); + }); + + it('should keep header and body column widths aligned when horizontally constrained', async () => { + fix = TestBed.createComponent(TreeGridZonelessAutosizeComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + await fix.whenStable(); + + const horizontalScroller = treeGrid.headerContainer.getScroll(); + expect(horizontalScroller.scrollWidth).toBeGreaterThan(horizontalScroller.clientWidth); + + const expectRenderedColumnsAligned = () => { + const cells = Array.from(treeGrid.gridAPI.get_row_by_index(0).cells); + expect(cells.length).toBeGreaterThan(1); + + for (const cell of cells.filter(renderedCell => renderedCell.column.field !== 'ID')) { + const header = TreeGridFunctions.getHeaderCellMultiColHeaders(fix, cell.column.field).nativeElement; + const headerWidth = header.getBoundingClientRect().width; + const cellWidth = cell.nativeElement.getBoundingClientRect().width; + + expect(Math.abs(headerWidth - cellWidth)) + .withContext(`column ${cell.column.field}`) + .toBeLessThanOrEqual(1); + } + }; + + expectRenderedColumnsAligned(); + + const chunkLoad = firstValueFrom(treeGrid.parentVirtDir.chunkLoad); + horizontalScroller.scrollLeft = horizontalScroller.scrollWidth; + horizontalScroller.dispatchEvent(new Event('scroll')); + await chunkLoad; + await fix.whenStable(); + + expectRenderedColumnsAligned(); + }); + }); }); + +@Component({ + template: ` + + @for (column of columns; track column) { + + } + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +class TreeGridZonelessAutosizeComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public columns = ['ID', 'ParentID', 'EmployeeName', 'Department', 'Office', 'Country', 'Project', 'Status']; + public data = Array.from({ length: 40 }, (_row, index) => ({ + ID: index, + ParentID: index === 0 ? null : 0, + EmployeeName: `Employee with a long display name ${index}`, + Department: `International Operations Department ${index}`, + Office: `Regional office location ${index}`, + Country: `Country name ${index}`, + Project: `Long running project ${index}`, + Status: `Current status ${index}` + })); +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 1fc344f8240..3b4f99b4e67 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -1,13 +1,13 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxTreeGridComponent } from './public_api'; -import { IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; +import { IgxTreeGridManyColumnsComponent, IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; -import { DebugElement } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; +import { filter, firstValueFrom } from 'rxjs'; import { CellType } from 'igniteui-angular/grids/core'; import { SCROLL_THROTTLE_TIME_MULTIPLIER } from './../../grid/src/grid-base.directive'; @@ -847,3 +847,48 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { }); }); }); + +describe('IgxTreeGrid keyboard navigation in zoneless change detection #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let gridContent: DebugElement; + let treeColumns: string[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTreeGridManyColumnsComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 } + ] + }); + fix = TestBed.createComponent(IgxTreeGridManyColumnsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + treeColumns = fix.componentInstance.columns; + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should activate a virtualized target cell with Ctrl + End', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + const horizontalScroll = treeGrid.headerContainer.getScroll(); + expect(horizontalScroll.scrollWidth).toBeGreaterThan(horizontalScroll.clientWidth); + expect(treeGrid.gridAPI.get_cell_by_index(2, treeColumns[treeColumns.length - 1])).toBeUndefined(); + + let cell = treeGrid.gridAPI.get_cell_by_index(2, treeColumns[1]); + UIInteractions.simulateClickAndSelectEvent(cell); + await fix.whenStable(); + + const selected = firstValueFrom(treeGrid.selected.pipe( + filter(({ cell: selectedCell }) => selectedCell.row.index === 9 && + selectedCell.column.field === treeColumns[treeColumns.length - 1]) + )); + UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); + await selected; + await fix.whenStable(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts b/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts index 2c233413eac..e1ceb01f3b9 100644 --- a/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts +++ b/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts @@ -124,6 +124,37 @@ export class IgxTreeGridWithScrollsComponent { public data = SampleTestData.employeeAllTypesTreeData(); } +@Component({ + template: ` + + @for (column of columns; track column) { + + } + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridManyColumnsComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public columns = ['ID', ...Array.from({ length: 15 }, (_, index) => `Value${index + 1}`)]; + public data = this.addColumnValues(SampleTestData.employeeAllTypesTreeData()); + + private addColumnValues(records: any[]): any[] { + return records.map((record, rowIndex) => { + const result = { ...record }; + for (const column of this.columns.slice(1)) { + result[column] = `${column}-${rowIndex}`; + } + if (record.Employees) { + result.Employees = this.addColumnValues(record.Employees); + } + return result; + }); + } +} + @Component({ template: ` From 48ba97ff5b801b4ca01bda16d9af0d4f08527157 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 14:56:03 +0300 Subject: [PATCH 27/32] test(grids): enable auto detection for deferred render tests --- .../grid/src/grid-cell-selection.spec.ts | 1 + .../grid/src/grid-keyBoardNav-headers.spec.ts | 1 + .../grids/grid/src/grid-keyBoardNav.spec.ts | 1 + .../grid/src/grid-mrl-keyboard-nav.spec.ts | 1 + .../grids/grid/src/grid.master-detail.spec.ts | 1 + .../src/hierarchical-grid.navigation.spec.ts | 13 ++++++++ .../hierarchical-grid.virtualization.spec.ts | 1 + .../src/tree-grid-keyBoardNav.spec.ts | 3 ++ .../tree-grid/src/tree-grid-summaries.spec.ts | 1 + .../test-utils/helper-utils.spec.ts | 31 ------------------- 10 files changed, 23 insertions(+), 31 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts index f16d7153300..5134ecba738 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -1445,6 +1445,7 @@ describe('IgxGrid - Cell selection #grid', () => { })); it('Should handle Shift + Ctrl + End keys combination', (async () => { + fix.autoDetectChanges(); const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts index 2e17e4df0e5..392c484add7 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts @@ -62,6 +62,7 @@ describe('IgxGrid - Headers Keyboard navigation #grid', () => { }); it('should focus first header when the grid is scrolled', async () => { + fix.autoDetectChanges(); grid.navigateTo(7, 5); await wait(250); fix.detectChanges(); diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts index 91b3e06a24e..d250a5b7e33 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts @@ -494,6 +494,7 @@ describe('IgxGrid - Keyboard navigation #grid', () => { }); it('should allow navigating first/last cell in column with home/end and Cntr key.', async () => { + fix.autoDetectChanges(); fix.componentInstance.columns = fix.componentInstance.generateCols(50); fix.componentInstance.data = fix.componentInstance.generateData(500); fix.detectChanges(); diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index ed20e72644b..f05fcc4a6e6 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -1728,6 +1728,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { it(`should navigate to the last cell from the layout by pressing Home/End and Ctrl key and keep same rowStart from the first selection when last cell spans more rows`, async () => { + fix.autoDetectChanges(); fix.componentInstance.colGroups = [{ group: 'group1', hidden: true, diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index 52761079902..199bae74333 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -665,6 +665,7 @@ describe('IgxGrid Master Detail #grid', () => { }); it('Should navigate to the first data row using Ctrl + ArrowUp when all rows are expanded.', async () => { + fix.autoDetectChanges(); setupGridScrollDetection(fix, grid); grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); await wait(DEBOUNCE_TIME); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts index 7ef755f3f29..aa110df550b 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -151,6 +151,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should allow navigating to end in child grid when child grid target row moves outside the parent view port.', async () => { + fixture.autoDetectChanges(); const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; const childCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; GridFunctions.focusCell(fixture, childCell); @@ -173,6 +174,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { + fixture.autoDetectChanges(); hierarchicalGrid.verticalScrollContainer.scrollTo(2); fixture.detectChanges(); await wait(DEBOUNCE_TIME); @@ -368,6 +370,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should move activation to last data cell in grid when ctrl+end is used.', async () => { + fixture.autoDetectChanges(); const parentCell = hierarchicalGrid.dataRowList.first.cells.first; GridFunctions.focusCell(fixture, parentCell); @@ -509,6 +512,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should skip nested child grids that have no data when navigating up/down', async () => { + fixture.autoDetectChanges(); const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; child1.height = '150px'; await wait(); @@ -668,6 +672,7 @@ describe('IgxHierarchicalGrid Navigation', () => { // complex tests it('in case prev cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + fixture.autoDetectChanges(); // scroll parent so that child top is not in view await wait(DEBOUNCE_TIME); fixture.detectChanges(); @@ -715,6 +720,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('in case next cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + fixture.autoDetectChanges(); const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; const nestedChild = child.gridAPI.getChildGrids(false)[0]; const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; @@ -737,6 +743,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should allow navigating up from parent into nested child grid', async () => { + fixture.autoDetectChanges(); hierarchicalGrid.verticalScrollContainer.scrollTo(2); await wait(DEBOUNCE_TIME); fixture.detectChanges(); @@ -782,6 +789,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should allow navigating up between sibling child grids.', async () => { + fixture.autoDetectChanges(); hierarchicalGrid.verticalScrollContainer.scrollTo(2); fixture.detectChanges(); await wait(); @@ -830,6 +838,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should navigate up from parent row to the correct child sibling.', async () => { + fixture.autoDetectChanges(); const parentCell = hierarchicalGrid.dataRowList.toArray()[1].cells.first; GridFunctions.focusCell(fixture, parentCell); @@ -863,6 +872,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should navigate to last cell in previous child using Arrow Up from last cell of sibling with more columns', async () => { + fixture.autoDetectChanges(); const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; childGrid2.dataRowList.first.virtDirRow.scrollTo(7); @@ -920,6 +930,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should navigate to last cell in next row for child grid using Arrow Up from last cell of parent with more columns', async () => { + fixture.autoDetectChanges(); hierarchicalGrid.verticalScrollContainer.scrollTo(2); fixture.detectChanges(); await wait(); @@ -942,6 +953,7 @@ describe('IgxHierarchicalGrid Navigation', () => { }); it('should navigate to last cell in next child using Arrow Down from last cell of previous child with more columns', async () => { + fixture.autoDetectChanges(); const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; @@ -1004,6 +1016,7 @@ describe('IgxHierarchicalGrid Navigation', () => { expect(childGrid.getBoundingClientRect().bottom <= parentBottom && childGrid.getBoundingClientRect().top >= parentTop); }); it('should navigate to exact nested child grid with navigateToChildGrid.', async() => { + fixture.autoDetectChanges(); hierarchicalGrid.expandChildren = false; await wait(DEBOUNCE_TIME); hierarchicalGrid.primaryKey = 'ID'; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index 21baeff788d..c9010c1a97a 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -202,6 +202,7 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { }); it('should not lose scroll position after expanding a row when there are already expanded rows above.', async () => { + fixture.autoDetectChanges(); setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); // Expand two rows at the top diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts index 3b4f99b4e67..254018b67a3 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -412,6 +412,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { }); it('should navigate with arrow Up and Down keys', async () => { + fix.autoDetectChanges(); spyOn(treeGrid.selected, 'emit').and.callThrough(); const firstCell: CellType = treeGrid.gridAPI.get_cell_by_index(5, 'ID'); UIInteractions.simulateClickAndSelectEvent(firstCell); @@ -556,6 +557,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { }); it('should move to the top left/bottom right cell when navigate with Ctrl + Home/End keys', async () => { + fix.autoDetectChanges(); spyOn(treeGrid.selected, 'emit').and.callThrough(); let cell = treeGrid.gridAPI.get_cell_by_index(2, treeColumns[2]); @@ -640,6 +642,7 @@ describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { }); it('should change editable cell and scroll when Tab and Shift + Tab keys are pressed', async () => { + fix.autoDetectChanges(); treeGrid.getColumnByName('ID').editable = true; treeGrid.getColumnByName('Name').editable = true; treeGrid.getColumnByName('HireDate').editable = true; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index bc4762c7640..d0954db8f03 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -1707,6 +1707,7 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { it('should render rows correctly after collapse and expand', async () => { const fix = TestBed.createComponent(IgxTreeGridSummariesScrollingComponent); + fix.autoDetectChanges(); const treeGrid = fix.componentInstance.treeGrid; setupGridScrollDetection(fix, treeGrid); fix.detectChanges(); diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts index b7cac42522c..c7c81b44849 100644 --- a/projects/igniteui-angular/test-utils/helper-utils.spec.ts +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -25,38 +25,7 @@ import { Subscription } from 'rxjs'; export let gridsubscriptions: Subscription [] = []; -/** - * Coalesces change-detection requests into a single deferred pass. `NgZone.onStable` - * used to run the deferred grid callbacks at zone quiescence — after the scroll handler - * and its synchronous size corrections had completed. Running change detection - * synchronously from inside the scroll event would render mid-correction states instead. - */ -const scheduleDetectChanges = (detectChanges: () => void, isDestroyed: () => boolean) => { - let scheduled = false; - return () => { - if (scheduled) { - return; - } - scheduled = true; - setTimeout(() => { - scheduled = false; - if (!isDestroyed()) { - detectChanges(); - } - }); - }; -}; - export const setupGridScrollDetection = (fixture: ComponentFixture, grid: GridType) => { - // gridScroll is emitted synchronously from the scroll handler, upstream of the - // deferred (afterNextRender) chunkLoad emit, so this subscription bootstraps the - // render pass those deferred callbacks need — the manual-CD stand-in for the tick - // the framework scheduler guarantees in real applications. - const scheduleFixtureDetectChanges = scheduleDetectChanges( - () => fixture.detectChanges(), - () => fixture.componentRef.hostView.destroyed - ); - gridsubscriptions.push(grid.gridScroll.subscribe(scheduleFixtureDetectChanges)); gridsubscriptions.push(grid.verticalScrollContainer.chunkLoad.subscribe(() => fixture.detectChanges())); gridsubscriptions.push(grid.parentVirtDir.chunkLoad.subscribe(() => fixture.detectChanges())); gridsubscriptions.push(grid.activeNodeChange.subscribe(() => grid.cdr.detectChanges())); From 3b3b61aaa0919d21bc27f2e05e2c065fce77cd21 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 15:50:13 +0300 Subject: [PATCH 28/32] test(grid): use DOM events in filtering row tests --- .../core/src/filtering/grid-filtering.service.ts | 14 ++------------ .../grids/grid/src/grid-filtering-ui.spec.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts index 115d5b704a9..54d70b772b6 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy, inject, signal } from '@angular/core'; +import { Injectable, OnDestroy, inject } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil, first } from 'rxjs/operators'; import { IColumnResizeEventArgs, IFilteringEventArgs } from '../common/events'; @@ -21,17 +21,7 @@ export class IgxFilteringService implements OnDestroy { private iconService = inject(IgxIconService); protected _overlayService = inject(IgxOverlayService); - // Signal-backed so that OnPush views reading it in templates (e.g. the header row's - // filtering row @if) re-render when it changes, without relying on ZoneJS ticks. - private _isFilterRowVisible = signal(false); - - public get isFilterRowVisible(): boolean { - return this._isFilterRowVisible(); - } - - public set isFilterRowVisible(value: boolean) { - this._isFilterRowVisible.set(value); - } + public isFilterRowVisible = false; public filteredColumn: ColumnType = null; public selectedExpression: IFilteringExpression = null; diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts index a994f19bc9e..fa81813f0f5 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts @@ -1481,7 +1481,7 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { fix.detectChanges(); // Click string filter chip to show filter row. - GridFunctions.clickFilterCellChip(fix, 'ProductName'); + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); tick(200); // Verify arrows and chip area are not visible because there is no active filtering for the column. @@ -1506,7 +1506,9 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { fix.detectChanges(); expect(grid.rowList.length).toEqual(0); - GridFunctions.clickFilterCellChip(fix, 'ProductName'); + const filterIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix)[0]; + filterIndicator.nativeElement.click(); + fix.detectChanges(); tick(200); // remove first chip @@ -1617,7 +1619,7 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { grid.width = '700px'; fix.detectChanges(); - GridFunctions.clickFilterCellChip(fix, 'ProductName'); + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); // Add first chip. GridFunctions.typeValueInFilterRowInput('a', fix); @@ -2398,7 +2400,7 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { grid.rowSelection = GridSelectionMode.multiple; fix.detectChanges(); - GridFunctions.clickFilterCellChip(fix, 'ProductName'); + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); const frElem = filteringRow.nativeElement; @@ -2525,8 +2527,7 @@ describe('IgxGrid - Filtering Row UI actions #grid', () => { }); fix.detectChanges(); - GridFunctions.clickFilterCellChip(fix, 'ProductName'); - + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); const frElem = filteringRow.nativeElement; const expandBtn = fix.debugElement.query(By.css('.igx-grid__group-expand-btn')); From 08caaeca8acadf309143000c5c6adbe3f222e18d Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 15:51:48 +0300 Subject: [PATCH 29/32] chore(*): remove unnecessary blank line in IgxFilteringService --- .../grids/core/src/filtering/grid-filtering.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts index 54d70b772b6..a196db3474a 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts @@ -22,7 +22,6 @@ export class IgxFilteringService implements OnDestroy { protected _overlayService = inject(IgxOverlayService); public isFilterRowVisible = false; - public filteredColumn: ColumnType = null; public selectedExpression: IFilteringExpression = null; public columnToMoreIconHidden = new Map(); From a4c952e2ff5fbf1d5bdd89308502e989c566de7f Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 16:09:26 +0300 Subject: [PATCH 30/32] refactor(hgrid): remove unnecessary scroll workarounds --- .../src/hierarchical-grid-navigation.service.ts | 4 ++-- .../src/hierarchical-grid.virtualization.spec.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts index ec6f875435e..0226ea1eca4 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts @@ -165,13 +165,13 @@ export class IgxHierarchicalGridNavigationService extends IgxGridNavigationServi this._pendingNavigation = true; const scrollableGrid = isNext ? this.getNextScrollableDown(this.grid) : this.getNextScrollableUp(this.grid); scrollableGrid.grid.verticalScrollContainer.recalcUpdateSizes(); + scrollableGrid.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); scrollableGrid.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { this._pendingNavigation = false; if (cb) { cb(); } }); - scrollableGrid.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); } else { if (cb) { cb(); @@ -221,10 +221,10 @@ export class IgxHierarchicalGridNavigationService extends IgxGridNavigationServi return; } const positionInfo = this.getElementPosition(childGrid.nativeElement, false); + this.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); this.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { childGrid.navigation.navigateToChildGrid(pathToChildGrid, cb); }); - this.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); }); } diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index c9010c1a97a..525553d40dc 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -203,7 +203,6 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { it('should not lose scroll position after expanding a row when there are already expanded rows above.', async () => { fixture.autoDetectChanges(); - setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); // Expand two rows at the top (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); @@ -259,7 +258,6 @@ describe('IgxHierarchicalGrid Virtualization #hGrid', () => { hierarchicalGrid.navigation.containerTopOffset - topOffset ).toBeLessThanOrEqual(1); - clearGridSubs(); }); it('should be able to scroll last row in view after all rows get expanded.', async () => { From a133942c7c23197c80ab41aeb7baa735e4d6de33 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 18:05:31 +0300 Subject: [PATCH 31/32] chore(*): remove leftover from --- .../grids/grid/src/grid.master-detail.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index 199bae74333..00708edb499 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -1298,10 +1298,6 @@ describe('IgxGrid Master Detail zoneless change detection #grid', () => { }).compileComponents(); })); - afterEach(() => { - clearGridSubs(); - }); - it('should navigate to the last data cell in the grid using Ctrl + End', async () => { fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); fix.detectChanges(); From 44ad8dd905651a66af4deb39952c4a1f37948528 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 20 Jul 2026 18:21:08 +0300 Subject: [PATCH 32/32] test(grids): use auto detection for render callbacks --- .../grids/grid/src/grid-mrl-keyboard-nav.spec.ts | 8 +------- .../grids/grid/src/grid.search.spec.ts | 13 +++---------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts index f05fcc4a6e6..65cf95fb951 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -1,7 +1,6 @@ import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { firstValueFrom } from 'rxjs'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; @@ -1916,6 +1915,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { }); it('should scroll active cell fully in view when navigating with arrow keys and row is partially visible.', async () => { + fix.autoDetectChanges(); fix.componentInstance.colGroups = [ { group: 'group1', @@ -1993,12 +1993,9 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow right - let verticalChunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); await wait(DEBOUNCE_TIME); fix.detectChanges(); - await verticalChunkLoad; - fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(2, 'Address'); @@ -2015,12 +2012,9 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { fix.detectChanges(); // arrow left - verticalChunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); await wait(DEBOUNCE_TIME); fix.detectChanges(); - await verticalChunkLoad; - fix.detectChanges(); // check next cell is active and is fully in view cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); diff --git a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts index 4b0341a0416..f5791a510ce 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts @@ -1073,8 +1073,8 @@ describe('IgxGrid - search API #grid', () => { }); it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { + fix.autoDetectChanges(); grid.height = '500px'; - await fix.whenStable(); fix.detectChanges(); grid.groupBy({ @@ -1083,21 +1083,14 @@ describe('IgxGrid - search API #grid', () => { ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); - await fix.whenStable(); fix.detectChanges(); - grid.findNext('a'); await wait(); - await fix.whenStable(); + fix.detectChanges(); - const chunkLoad = firstValueFrom(grid.verticalScrollContainer.chunkLoad); (grid as any).scrollTo(9, 0); + await firstValueFrom(grid.verticalScrollContainer.chunkLoad); fix.detectChanges(); - await wait(); - await fix.whenStable(); - fix.detectChanges(); - await chunkLoad; - const row = grid.gridAPI.get_row_by_index(9); const spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); expect(spans.length).toBe(5);