diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index bf3bc6ca863..50326e3744e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -18,9 +18,6 @@ jobs: cache: 'npm' registry-url: 'https://registry.npmjs.org' - - name: Update NPM - run: npm install -g npm@latest - - run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV - run: echo ${VERSION} diff --git a/README.md b/README.md index 6adaeeaebf0..1c481566ea1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Or visit [Ignite UI for Angular Discord](https://discord.com/channels/8366344874 ### Contributing There are many ways in which you can [participate](https://github.com/IgniteUI/igniteui-angular/blob/master/.github/CONTRIBUTING.md#overview) in this project, for example: +Local development of this repository requires **Node.js 22 or later** before running `npm install` or the build/test scripts. + - [Submit bugs and feature requests](https://github.com/IgniteUI/igniteui-angular/wiki/How-to-log-an-Issue-on-Github), and help us verify as they are checked in. - Review [source code changes](https://github.com/IgniteUI/igniteui-angular/pulls) - Review [the documentation](https://github.com/IgniteUI/igniteui-docfx) and make pull requests for anything from typos to additional and new content diff --git a/package-lock.json b/package-lock.json index e1724542f6e..b8912ff0f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "igniteui-angular-charts": "^22.0.0", "igniteui-angular-core": "^22.0.0", "igniteui-dockmanager": "^1.17.0", - "igniteui-grid-lite": "~0.7.1", + "igniteui-grid-lite": "~0.9.0", "igniteui-i18n-resources": "^1.0.5", "igniteui-sassdoc-theme": "^2.1.0", "igniteui-webcomponents": "^7.2.1", @@ -108,6 +108,7 @@ "stylelint-prettier": "^5.0.2", "stylelint-scss": "^6.9.0", "ts-node": "^10.8.1", + "tsconfig-paths": "^4.2.0", "typedoc": "^0.28.14", "typedoc-plugin-localization": "^3.1.0", "typescript": "6.0.3" @@ -14512,60 +14513,19 @@ "license": "Apache-2.0" }, "node_modules/igniteui-grid-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/igniteui-grid-lite/-/igniteui-grid-lite-0.7.1.tgz", - "integrity": "sha512-CUKa9/U5Tn1DDxPMoHPP5RRbTlA/KqiSaHGDaMrrGNRBFbLQAZJnKNR3mlTgHeIk0rWjL6Wyc2bZgjpTWRQxIQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/igniteui-grid-lite/-/igniteui-grid-lite-0.9.0.tgz", + "integrity": "sha512-pW6MPniC0Up5YmbdB2QVTpPdIp0lkapPfQbnQQmqhtrvGO8Cpf84FQf1s7uMxatCC6FV5w7fgGpriy3t88yNng==", "dev": true, "license": "MIT", "dependencies": { "@lit-labs/virtualizer": "~2.1.0", "@lit/context": "~1.1.5", - "igniteui-webcomponents": "~7.1.0", - "lit": "^3.3.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/igniteui-grid-lite/node_modules/igniteui-webcomponents": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/igniteui-webcomponents/-/igniteui-webcomponents-7.1.3.tgz", - "integrity": "sha512-iG4xBzooIOxtnAG8VIg39zKVWoo9jy2iEK3CDLGZYb9hU1LZx+4SAOPFkB9FSp15dgR4ql6kGKYf93T8oK0sdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.0", - "@lit-labs/virtualizer": "^2.1.0", - "@lit/context": "^1.1.0", - "igniteui-i18n-core": "^1.0.1", + "igniteui-webcomponents": "~7.2.0", "lit": "^3.3.0" }, "engines": { "node": ">=22" - }, - "peerDependencies": { - "dompurify": "^3.3.0", - "igniteui-i18n-resources": "^1.0.1", - "marked": "^17.0.0", - "marked-shiki": "^1.2.0", - "shiki": "^3.20.0" - }, - "peerDependenciesMeta": { - "dompurify": { - "optional": true - }, - "igniteui-i18n-resources": { - "optional": true - }, - "marked": { - "optional": true - }, - "marked-shiki": { - "optional": true - }, - "shiki": { - "optional": true - } } }, "node_modules/igniteui-i18n-core": { @@ -24786,6 +24746,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -25848,6 +25818,21 @@ "node": ">=0.3.1" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 71367f87051..860af822aad 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:lib:pgrid": "ng test igniteui-angular --watch=false --no-progress --code-coverage --karma-config=./projects/igniteui-angular/karma.pivot-grid.conf.js", "test:lib:others": "ng test igniteui-angular --watch=false --no-progress --code-coverage --karma-config=./projects/igniteui-angular/karma.non-grid.conf.js", "test:lib:watch": "ng test igniteui-angular --karma-config=./projects/igniteui-angular/karma.watch.conf.js", - "test:schematics": "gulp copySchematics && tsc --project ./projects/igniteui-angular/schematics/tsconfig.json && gulp copyMigrations && tsc --project ./projects/igniteui-angular/migrations/tsconfig.spec.json && node ./node_modules/jasmine/bin/jasmine.js \"dist/igniteui-angular/migrations/**/*.spec.js\" \"dist/igniteui-angular/schematics/**/*.spec.js\"", + "test:schematics": "gulp copySchematics && tsc --project ./projects/igniteui-angular/schematics/tsconfig.json && gulp copyMigrations && tsc --project ./projects/igniteui-angular/migrations/tsconfig.spec.json && node -r ./projects/igniteui-angular/migrations/tsconfig-paths-bootstrap.js ./node_modules/jasmine/bin/jasmine.js \"dist/igniteui-angular/migrations/**/*.spec.js\" \"dist/igniteui-angular/schematics/**/*.spec.js\"", "test:styles": "ts-node --skip-project ./node_modules/jasmine/bin/jasmine.js ./projects/igniteui-angular/core/src/core/styles/spec/tests.mjs", "test:i18n": "ts-node --skip-project ./projects/igniteui-angular/core/src/core/i18n/tests/tests.mjs", "test:elements": "ng test igniteui-angular-elements --watch=false --no-progress --code-coverage --source-map=false", @@ -133,7 +133,7 @@ "igniteui-angular-charts": "^22.0.0", "igniteui-angular-core": "^22.0.0", "igniteui-dockmanager": "^1.17.0", - "igniteui-grid-lite": "~0.7.1", + "igniteui-grid-lite": "~0.9.0", "igniteui-i18n-resources": "^1.0.5", "igniteui-sassdoc-theme": "^2.1.0", "igniteui-webcomponents": "^7.2.1", @@ -163,6 +163,7 @@ "stylelint-prettier": "^5.0.2", "stylelint-scss": "^6.9.0", "ts-node": "^10.8.1", + "tsconfig-paths": "^4.2.0", "typedoc": "^0.28.14", "typedoc-plugin-localization": "^3.1.0", "typescript": "6.0.3" 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..c3c344d4b6f 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -204,11 +204,10 @@ describe('Elements: ', () => { `; testContainer.innerHTML = innerHtml; + const grid = document.querySelector>('#testGrid'); - // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + await firstValueFrom(fromEvent(grid, "childrenResolved")); - 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"]'); @@ -217,7 +216,7 @@ describe('Elements: ', () => { expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); grid.removeChild(secondGroup); - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + await firstValueFrom(fromEvent(grid, "childrenResolved")); expect(grid.columns.length).toEqual(4); expect(grid.getColumnByName('ProductID')).toBeTruthy(); @@ -229,7 +228,7 @@ describe('Elements: ', () => { newColumn.setAttribute('field', 'ProductName'); newGroup.appendChild(newColumn); grid.insertBefore(newGroup, thirdGroup); - await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + await firstValueFrom(fromEvent(grid, "childrenResolved")); expect(grid.columns.length).toEqual(6); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); diff --git a/projects/igniteui-angular-elements/src/lib/state.component.ts b/projects/igniteui-angular-elements/src/lib/state.component.ts index a1bb26414a6..e8307cd35ab 100644 --- a/projects/igniteui-angular-elements/src/lib/state.component.ts +++ b/projects/igniteui-angular-elements/src/lib/state.component.ts @@ -31,6 +31,8 @@ export interface IGridStateInfo { /* blazorIndirectRender */ /* singleInstanceIdentifier */ /* contentParent: GridBaseDirective */ +/* contentParent: Grid */ +/* contentParent: TreeGrid */ /* contentParent: HierarchicalGrid */ /* jsonAPIManageCollectionInMarkup */ /** diff --git a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts index cd2bed4c26a..56621c2416e 100644 --- a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts +++ b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts @@ -52,7 +52,8 @@ export class IgxActionStripMenuItemDirective { /* wcElementTag: igc-action-strip */ /* blazorIndirectRender */ /* singleInstanceIdentifier */ -/* contentParent: GridBaseDirective */ +/* contentParent: Grid */ +/* contentParent: TreeGrid */ /* contentParent: RowIsland */ /* contentParent: HierarchicalGrid */ /** diff --git a/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts b/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts index 74156a6b395..6f5cc635456 100644 --- a/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts +++ b/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts @@ -6,6 +6,7 @@ import { prefixResource } from 'igniteui-i18n-core'; +/* blazorPlainObject */ export type IGridResourceStrings = PrefixedResourceStrings; export const GridResourceStringsEN: IGridResourceStrings = prefixResource(IGX_PREFIX, AGridResourceStrings); diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss index 29463e45c6b..882ae93824e 100644 --- a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss @@ -464,6 +464,10 @@ @extend %igx-grid__outlet !optional; } + @include e(loading-outlet) { + @extend %igx-grid__loading-outlet !optional; + } + @include e(addrow-snackbar) { @extend %igx-grid__addrow-snackbar !optional; } diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss index 2295bc01c7a..49f7c929c34 100644 --- a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss @@ -2374,6 +2374,12 @@ --ig-size: var(--grid-size); } + %igx-grid__loading-outlet { + %overlay-wrapper--modal { + background: none; + } + } + %igx-grid__addrow-snackbar { position: absolute; z-index: 5; diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index f7f6a7b66e2..b58c660f2a7 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,31 @@ 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'; + +interface AfterNextRenderSpec { + earlyRead?: () => void; + write?: () => void; + mixedReadWrite?: () => void; + read?: () => void; +} + +/** + * 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: AfterNextRenderSpec = {}; + spec[phase as keyof AfterNextRenderSpec] = callback; + return afterNextRender(spec, { injector }); +} /** * Returns true if the element's direction is left-to-right @@ -416,6 +441,7 @@ export const flatten = (arr: any[]) => { }; export interface CancelableEventArgs { + /* csSuppress */ /** * Provides the ability to cancel the event. */ diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts b/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts index 94dbf76f411..993b6387d22 100644 --- a/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts +++ b/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts @@ -20,6 +20,7 @@ export declare interface IExpressionTree { /* alternateBaseType: ExpressionTree */ /* marshalByValue */ +/* skipEventDetails */ export declare interface IFilteringExpressionsTree extends IBaseEventArgs, IExpressionTree { filteringOperands: (IFilteringExpressionsTree | IFilteringExpression)[]; /* alternateName: treeType */ @@ -41,6 +42,7 @@ export declare interface IFilteringExpressionsTree extends IBaseEventArgs, IExpr /* marshalByValue */ /* jsonAPIPlainObject */ +/* skipEventDetails */ export class FilteringExpressionsTree implements IFilteringExpressionsTree { /** diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts index 489805e51c7..97b9ca14f0e 100644 --- a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts @@ -927,6 +927,11 @@ export class IgxDragDirective implements AfterContentInit, OnDestroy { * @param event PointerDown event captured */ public onPointerDown(event) { + // Start drag only with the primary pointer button. + if ((this.pointerEventsEnabled || !this.touchEventsEnabled) && 'button' in event && event.button !== 0) { + return; + } + const ignoredElement = this.dragIgnoredElems.find(elem => elem.element.nativeElement === event.target); if (ignoredElement) { return; diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts index 35290451d67..e491a8f6283 100644 --- a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts @@ -2003,6 +2003,41 @@ describe('igxDrag touch, mouse, pointerLost and shadow root coverage', () => { await wait(); }); + it('should not initiate drag on secondary pointer button', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + spyOn(firstDrag.dragStart, 'emit'); + spyOn(firstDrag.dragClick, 'emit'); + + const pointerDown = new PointerEvent('pointerdown', { + view: window, + bubbles: true, + cancelable: true, + pointerId: 1, + button: 2 + }); + Object.defineProperty(pointerDown, 'pageX', { value: startingX, enumerable: true }); + Object.defineProperty(pointerDown, 'pageY', { value: startingY, enumerable: true }); + firstElement.dispatchEvent(pointerDown); + fix.detectChanges(); + await wait(); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostElement).not.toBeDefined(); + }); + it('should call onPointerLost early return when _clicked is false', async () => { const firstDrag = fix.componentInstance.dragElems.first; 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..81d87eab2fc 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 @@ -358,6 +358,38 @@ describe('IgxForOf directive -', () => { expect(cache).toEqual([130, 100, 100, 100, 100, 100, 100, 130, 130, 130]); }); + it('should take item borders and margins into account when calculating its size', () => { + const virtualContainer = fix.componentInstance.parentVirtDir; + const node = document.createElement('div'); + node.style.width = '100px'; + node.style.height = '80px'; + node.style.border = '2px solid transparent'; + node.style.margin = '3px 5px 7px 11px'; + fix.nativeElement.appendChild(node); + + virtualContainer.igxForScrollOrientation = 'vertical'; + const verticalSize = node.getBoundingClientRect().height + 3 + 7; + expect(virtualContainer.testGetNodeSize(node)).toBe(verticalSize); + + virtualContainer.igxForScrollOrientation = 'horizontal'; + virtualContainer.igxForSizePropName = 'width'; + const horizontalSize = node.getBoundingClientRect().width + 5 + 11; + expect(virtualContainer.testGetNodeSize(node)).toBe(horizontalSize); + + node.remove(); + }); + + it('should preserve valid border sizes when another side cannot be parsed', () => { + const virtualContainer = fix.componentInstance.parentVirtDir; + const node = document.createElement('div'); + spyOn(window, 'getComputedStyle').and.returnValue({ + borderTopWidth: '', + borderBottomWidth: '2px' + } as CSSStyleDeclaration); + + expect(virtualContainer.testGetBorder(node, 'height')).toBe(2); + }); + it('should render no more that initial chunk size elements when set if no containerSize', () => { fix.componentInstance.height = undefined; fix.componentInstance.initialChunkSize = 3; @@ -1386,6 +1418,14 @@ export class TestIgxForOfDirective extends IgxForOfDirective { public testGetHorizontalIndexAt(left, set) { super.getIndexAt(left, set); } + + public testGetNodeSize(node: Element): number { + return super.getNodeSize(node, 0); + } + + public testGetBorder(node: Element, dimension: string): number { + return super.getBorder(node, dimension); + } } /** Empty virtualized component */ 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..b1ce3e59401 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, 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; @@ -852,8 +848,8 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken extends IgxForOfToken { - afterNextRender({ - write: () => { - this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; - } - }); - }); - - this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this)); + runAfterRenderOnce(this._injector, () => { + this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; + }, 'write'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes()); this.dc.changeDetectorRef.detectChanges(); if (prevStartIndex !== this.state.startIndex) { @@ -1182,7 +1173,7 @@ export class IgxForOfDirective extends IgxForOfToken this.recalcUpdateSizes()); this.dc.changeDetectorRef.detectChanges(); if (prevStartIndex !== this.state.startIndex) { @@ -1575,6 +1566,16 @@ export class IgxForOfDirective extends IgxForOfToken type.name || typeof type; @@ -1686,7 +1687,10 @@ export class IgxGridForOfDirective extends IgxForOfDirec super.ngOnInit(); this.removeScrollEventListeners(); const destructor = takeUntil(this.destroy$); - this.viewObserver = new (getResizeObserver())((entries: ResizeObserverEntry[]) => this.viewResizeNotify.next(entries)); + const resizeObserver = getResizeObserver(); + if (resizeObserver) { + this.viewObserver = new resizeObserver((entries: ResizeObserverEntry[]) => this.viewResizeNotify.next(entries)); + } this.viewResizeNotify.pipe( filter(() => this.igxForContainerSize && this.igxForOf && this.igxForOf.length > 0), destructor @@ -1785,14 +1789,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._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this, prevState)); - } - }); - }); + runAfterRenderOnce(this._injector, () => { + this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`; + }, 'write'); + runAfterRenderOnce(this._injector, () => this.recalcUpdateSizes(prevState)); this.cdr.markForCheck(); } diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts index 77bca12da8a..9e5c1d9862d 100644 --- a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts @@ -27,6 +27,8 @@ export interface ITooltipHideEventArgs extends IBaseEventArgs { cancel: boolean; } +const HOVER_SHOW_TRIGGERS = new Set(['mouseenter', 'mouseover', 'pointerenter', 'pointerover']); + /** * **Ignite UI for Angular Tooltip Target** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tooltip) @@ -564,8 +566,14 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen this._pendingShowTrigger = triggerEvent?.type ?? null; this.target.timeoutId = setTimeout(() => { - // Call open() of IgxTooltipDirective + const isHoverTrigger = this._pendingShowTrigger && HOVER_SHOW_TRIGGERS.has(this._pendingShowTrigger); this._pendingShowTrigger = null; + this.target.timeoutId = null; + + if (isHoverTrigger && !this.nativeElement.matches(':hover')) { + return; + } + this.target.open(this._mergedOverlaySettings); }, withDelay ? this.showDelay : 0); } diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts index 318b4e0f1e6..aaa8d5536bf 100644 --- a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts @@ -15,6 +15,7 @@ const SHOW_DELAY = 200; const HIDE_DELAY = 300; const AUTO_HIDE_DELAY = 180; const TOOLTIP_ARROW_SELECTOR = '[data-arrow="true"]'; +const hoveredElements = new WeakSet(); describe('IgxTooltip', () => { let fix: ComponentFixture; @@ -23,6 +24,11 @@ describe('IgxTooltip', () => { let button: DebugElement; beforeEach(waitForAsync(() => { + const matches = Element.prototype.matches; + spyOn(Element.prototype, 'matches').and.callFake(function(this: Element, selectors: string): boolean { + return selectors === ':hover' ? hoveredElements.has(this) : matches.call(this, selectors); + } as typeof Element.prototype.matches); + TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, @@ -532,6 +538,22 @@ describe('IgxTooltip', () => { tick(300); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); + + for (const trigger of ['mouseenter', 'mouseover', 'pointerenter', 'pointerover']) { + it(`should not open after the delay when the target is no longer hovered using ${trigger}`, fakeAsync(() => { + tooltipTarget.showDelay = 500; + tooltipTarget.showTriggers = trigger; + tooltipTarget.hideTriggers = 'click'; + fix.detectChanges(); + + hoverElement(button, trigger); + tick(300); + unhoverElement(button); + tick(200); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + } }); }); @@ -1169,9 +1191,15 @@ interface ElementRefLike { nativeElement: HTMLElement } -const hoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); +const hoverElement = (element: ElementRefLike, event = 'pointerenter') => { + hoveredElements.add(element.nativeElement); + element.nativeElement.dispatchEvent(new MouseEvent(event)); +}; -const unhoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('pointerleave')); +const unhoverElement = (element: ElementRefLike) => { + hoveredElements.delete(element.nativeElement); + element.nativeElement.dispatchEvent(new MouseEvent('pointerleave')); +}; const simulateTriggerEvent = (element: ElementRefLike, event: string) => element.nativeElement.dispatchEvent(new Event(event, { bubbles: true })); @@ -1188,7 +1216,7 @@ const alignmentTolerance = 2; export const verifyTooltipPosition = ( tooltipNativeElement: HTMLElement, actualTarget: { nativeElement: HTMLElement }, - shouldAlign:boolean = true, + shouldAlign: boolean = true, placement: Placement = Placement.Bottom, offset: number = 6 ) => { diff --git a/projects/igniteui-angular/grids/core/src/common/events.ts b/projects/igniteui-angular/grids/core/src/common/events.ts index 7de811d9017..7a7667eec67 100644 --- a/projects/igniteui-angular/grids/core/src/common/events.ts +++ b/projects/igniteui-angular/grids/core/src/common/events.ts @@ -95,6 +95,7 @@ export interface IGridEditDoneEventArgs extends IBaseEventArgs { * Indicates if the editing consists of adding a new row */ isAddRow?: boolean; + /* blazorSuppress */ /** * Optional * Indicates if the new value would be valid. diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts index 18649187a00..0ae02d833eb 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts @@ -363,7 +363,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { */ public get containerSize() { if (this.esf.listData.length) { - return this.list?.element.nativeElement.offsetHeight; + return this.list?.element.nativeElement.clientHeight; } // GE Nov 1st, 2021 #10355 Return a numeric value, so the chunk size is calculated properly. 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..b4195b8c32d 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, position.rowIndex, position.colIndex); if (NAVIGATION_KEYS.has(key)) { event.preventDefault(); this.navigateInBody(position.rowIndex, position.colIndex, (obj) => { @@ -231,6 +232,16 @@ export class IgxGridNavigationService { || containerHeight && endTopOffset - containerHeight > 5; } + 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/pivot-grid-dimensions.ts b/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts index 96efe72ff2a..687a8f6d8cf 100644 --- a/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts +++ b/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts @@ -63,6 +63,7 @@ export class IgxPivotDateDimension implements IPivotDimension { fullDate: true }; + /* treatAsRef */ /** * Gets/Sets the resource strings. * @@ -106,6 +107,8 @@ export class IgxPivotDateDimension implements IPivotDimension { public childLevel?: IPivotDimension; /** @hidden @internal */ public memberName = 'AllPeriods'; + /** @hidden @internal */ + public locale?: string; public displayName: string; private _resourceStrings: IGridResourceStrings = null; private _baseDimension: IPivotDimension; @@ -145,7 +148,7 @@ export class IgxPivotDateDimension implements IPivotDimension { memberFunction: (rec) => { const recordValue = PivotUtil.extractValueFromDimension(inBaseDimension, rec); const dateValue = recordValue ? getDateFormatter().createDateFromValue(recordValue) : null; - return recordValue ? getDateFormatter().formatDateTime(dateValue, undefined, { month: 'long'}) : rec['Months']; + return recordValue ? getDateFormatter().formatDateTime(dateValue, this.locale, { month: 'long'}) : rec['Months']; }, enabled: true, childLevel: baseDimension diff --git a/projects/igniteui-angular/grids/core/src/state-base.directive.ts b/projects/igniteui-angular/grids/core/src/state-base.directive.ts index 2d9aae8a97e..a644a53c70b 100644 --- a/projects/igniteui-angular/grids/core/src/state-base.directive.ts +++ b/projects/igniteui-angular/grids/core/src/state-base.directive.ts @@ -8,6 +8,7 @@ import { IgxColumnLayoutComponent } from './columns/column-layout.component'; import { IPivotConfiguration, IPivotDimension } from './pivot-grid.interface'; import { PivotUtil } from './pivot-util'; import { IgxPivotDateDimension } from './pivot-grid-dimensions'; +import { first } from 'rxjs/operators'; export interface IGridState { columns?: IColumnState[]; @@ -279,10 +280,22 @@ export class IgxGridStateBaseDirective { newColumns.push(ref); } }); - context.currGrid.updateColumns(newColumns); - newColumns.forEach(col => { - (context.currGrid as any).columnInit.emit(col); - }); + + if (!context.currGrid._init && !context.currGrid._rendered) { + // If grid is not rendered but is initialized (during ngAfterViewInit) wait for it to render. + // Otherwise pushing mid detect change (mainly while calculating sizes from `calculateGridWidth` and `calculateGridSizes`) messes up headers detect changing. + context.currGrid.rendered.pipe(first()).subscribe(() => { + context.currGrid.updateColumns(newColumns); + newColumns.forEach(col => { + (context.currGrid as any).columnInit.emit(col); + }); + }); + } else { + context.currGrid.updateColumns(newColumns); + newColumns.forEach(col => { + (context.currGrid as any).columnInit.emit(col); + }); + } } }, groupBy: { diff --git a/projects/igniteui-angular/grids/core/src/state.directive.spec.ts b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts index fc333a89790..adc73fa054e 100644 --- a/projects/igniteui-angular/grids/core/src/state.directive.spec.ts +++ b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts @@ -1,4 +1,4 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; +import { TestBed, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; import { Component, TemplateRef, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; import { IgxGridStateDirective } from './state.directive'; @@ -375,9 +375,10 @@ describe('IgxGridState - input properties #grid', () => { expect(gridState).toBe(columnsState); }); - it('setState should correctly restore grid columns state from object', () => { + it('setState should correctly restore grid columns state from object', fakeAsync(() => { const fix = TestBed.createComponent(IgxGridStateComponent); fix.detectChanges(); + tick(); const state = fix.componentInstance.state; const grid = fix.componentInstance.grid; spyOn(grid.columnInit, 'emit').and.callThrough(); @@ -394,7 +395,7 @@ describe('IgxGridState - input properties #grid', () => { gridState = state.getState(true, 'columns'); expect(gridState).toBe(columnsState); expect(grid.columnInit.emit).toHaveBeenCalledTimes(columnsStateObject.columns.length); - }); + })); it('setState should correctly restore grid columns state properties: collapsible and expanded', () => { const fix = TestBed.createComponent(CollapsibleColumnGroupTestComponent); @@ -421,9 +422,10 @@ describe('IgxGridState - input properties #grid', () => { expect(addressInfoGroup.expanded).toBe(false); }); - it('setState should correctly restore grid columns with Column Groups and same headers', () => { + it('setState should correctly restore grid columns with Column Groups and same headers', fakeAsync(() => { const fix = TestBed.createComponent(IgxGridStateComponent); fix.detectChanges(); + tick(); const state = fix.componentInstance.state; const initialState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; const columnsState = '{"columns":[{"pinned":false,"sortable":false,"filterable":false,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductID","width":"150px","header":"General Information","resizable":true,"searchable":true,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"","width":"398px","header":"General Information","resizable":false,"searchable":true,"selectable":true,"key":"ProductName_UnitsInStock","columnGroup":true,"disableHiding":false,"disablePinning":false,"collapsible":false,"expanded":true},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":false,"field":"ProductName","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","parentKey":"ProductName_UnitsInStock","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"UnitsInStock","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"UnitsInStock","parentKey":"ProductName_UnitsInStock","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"InStock","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true}]}'; @@ -437,11 +439,12 @@ describe('IgxGridState - input properties #grid', () => { gridState = state.getState(false, 'columns') as IGridState; HelperFunctions.verifyColumns(columnsStateObject.columns, gridState); - }); + })); - it('setState should reuse columns with matching keys and create new ones for the rest.', () => { + it('setState should reuse columns with matching keys and create new ones for the rest.', fakeAsync(() => { const fix = TestBed.createComponent(IgxGridStateComponent); fix.detectChanges(); + tick(); const state = fix.componentInstance.state; const grid = fix.componentInstance.grid; const originalColumns = [...grid.columns]; @@ -490,7 +493,7 @@ describe('IgxGridState - input properties #grid', () => { expect(x.bodyTemplate).toBe(fix.componentInstance.template); }); expect(grid.columns[grid.columns.length - 1 ].field).toBe("AnotherColumn"); - }); + })); it('setState should correctly restore grid paging state from string', () => { const fix = TestBed.createComponent(IgxGridStateComponent); @@ -766,9 +769,11 @@ describe('IgxGridState - input properties #grid', () => { expect(gridState).toBe(expansionState); }); - it('should correctly restore mrl column states.', () => { + it('should correctly restore mrl column states.', fakeAsync(() => { const fix = TestBed.createComponent(IgxGridMRLStateComponent); fix.detectChanges(); + tick(); + const grid = fix.componentInstance.grid; const state = fix.componentInstance.state; @@ -801,7 +806,7 @@ describe('IgxGridState - input properties #grid', () => { expect(prodIdColumn.rowEnd).toBe(4); expect(prodIdColumn.colStart).toBe(1); expect(prodIdColumn.colEnd).toBe(1); - }); + })); it('getState should not mutate live sorting expressions (strategy/owner)', () => { const fix = TestBed.createComponent(IgxGridStateComponent); diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts index 27bfdd1c513..76abe998194 100644 --- a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts @@ -14,7 +14,8 @@ import { IgxIconService } from 'igniteui-angular/icon'; /* wcElementTag: igc-grid-toolbar */ /* blazorIndirectRender */ /* singleInstanceIdentifier */ -/* contentParent: GridBaseDirective */ +/* contentParent: Grid */ +/* contentParent: TreeGrid */ /* contentParent: RowIsland */ /* contentParent: HierarchicalGrid */ /* jsonAPIManageItemInMarkup */ diff --git a/projects/igniteui-angular/grids/core/src/watch-changes.ts b/projects/igniteui-angular/grids/core/src/watch-changes.ts index 38e079ff337..5ad882b2201 100644 --- a/projects/igniteui-angular/grids/core/src/watch-changes.ts +++ b/projects/igniteui-angular/grids/core/src/watch-changes.ts @@ -22,7 +22,8 @@ export function WatchChanges(): PropertyDecorator { const oldValue = this[key]; if (val !== oldValue || (typeof val === 'object' && val === oldValue)) { originalSetter.call(this, val); - if (this.ngOnChanges && !init) { + // Explicitly check whether the decorator is called during initialization + if (this.ngOnChanges && init !== undefined && !init) { // in case wacthed prop changes trigger ngOnChanges manually const changes: SimpleChanges = { [key]: new SimpleChange(oldValue, val, false) 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..74c5eb11565 100644 --- a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -608,19 +608,22 @@ 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 availableWidth = parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; const locationColGroup = getColGroup(grid, 'Location'); - const colWidth = Math.floor(parseInt(availableWidth, 10) / 3); - const colWidthPx = colWidth + 'px'; - expect(locationColGroup.width).toBe((Math.round(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); }); 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 5d875b8fb09..3eff3d12e50 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'; @@ -51,7 +51,8 @@ describe('IgxGrid - Column properties #grid', () => { TemplatedContextInputColumnsComponent, ColumnHaederClassesComponent, ResizableColumnsComponent, - DOMAttributesAsSettersComponent + DOMAttributesAsSettersComponent, + GridInToggleableWrapperComponent ] }).compileComponents(); })); @@ -325,6 +326,29 @@ describe('IgxGrid - Column properties #grid', () => { expect(grid.columnList.get(1).width).toBe('300px'); }); + it('should not derive a NaN column width when the grid is hidden through its wrapper and all columns are sized', () => { + const fix = TestBed.createComponent(GridInToggleableWrapperComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + // Hide the grid through its wrapper (display: none) and force a size recalculation. + // With no width set, the hidden grid falls back to summing its column widths for + // calcWidth, so computedWidth equals sumExistingWidths while columnsToSize is 0. + fix.componentInstance.wrapperHidden = true; + fix.detectChanges(); + grid.reflow(); + fix.detectChanges(); + + const possibleWidth = grid.getPossibleColumnWidth(); + expect(possibleWidth).not.toContain('NaN'); + expect(Number.isFinite(parseFloat(possibleWidth))).toBe(true); + + // the minWidth column must keep a valid, finite pixel width rather than being poisoned by NaN + const minWidthColumn = grid.getColumnByName('field15'); + expect(Number.isFinite(minWidthColumn.calcPixelWidth)).toBe(true); + }); + it('should support passing templates through the markup as an input property', () => { const fixture = TestBed.createComponent(TemplatedInputColumnsComponent); fixture.detectChanges(); @@ -1539,6 +1563,54 @@ describe('IgxGrid - Column properties #grid', () => { expect(grid.columns.find(x => x.field === 'Fax').width).toBe('130px'); })); + it('should rebuild horizontal size cache for auto-sized columns when scrolled into view.', (fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + const cols = []; + const data = []; + for (let j = 0; j < 20; j++) { + cols.push({ + field: (j + 1).toString(), + width: 'auto' + }); + } + const obj = {}; + for (let j = 0; j < cols.length; j++) { + const col = cols[j].field; + obj[col] = j; + } + + for (let i = 0; i < 100; i++) { + const newObj = Object.create(obj); + newObj['ID'] = i; + data.push(newObj); + } + fix.componentInstance.columns = cols; + fix.componentInstance.data = data; + fix.detectChanges(); + tick(100); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + let state = grid.headerContainer.state; + let visibleColumnSizes = (grid.headerContainer as any).individualSizeCache.slice(state.startIndex, state.startIndex + state.chunkSize); + const expectedAutoSize = 68; + for (const val of visibleColumnSizes) { + expect(val).toBe(expectedAutoSize); + } + + const horizontalScroller = grid.headerContainer.getScroll(); + horizontalScroller.scrollLeft = horizontalScroller.scrollWidth; + horizontalScroller.dispatchEvent(new Event('scroll')); + tick(100); + fix.detectChanges(); + + state = grid.headerContainer.state; + expect(state.startIndex).not.toBe(0); + visibleColumnSizes = (grid.headerContainer as any).individualSizeCache.slice(state.startIndex, state.startIndex + state.chunkSize); + for (const val of visibleColumnSizes) { + expect(val).toBe(expectedAutoSize); + } + }))); + it('should auto-size correctly when cell has custom template', fakeAsync(() => { const fix = TestBed.createComponent(ResizableColumnsComponent); const grid = fix.componentInstance.instance; @@ -1942,3 +2014,52 @@ export class DOMAttributesAsSettersComponent { public data = [{ id: 1, value: 1 }]; } + +@Component({ + template: ` +
+ + + + +
+ `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridInToggleableWrapperComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public wrapperHidden = false; + public data = [ + { id: 1, field15: 'lorem' }, + { id: 2, field15: 'ipsum' } + ]; +} +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-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 8b0a93ac579..c60a413e8a4 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,9 @@ import { ViewContainerRef, DOCUMENT, inject, - InjectionToken + InjectionToken, + SimpleChanges, + OnChanges } from '@angular/core'; import { areEqualArrays, @@ -91,7 +93,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'; @@ -138,7 +141,7 @@ const MINIMUM_COLUMN_WIDTH = 136; wcSkipComponentSuffix */ @Directive() export abstract class IgxGridBaseDirective implements GridType, - OnInit, DoCheck, OnDestroy, AfterContentInit, AfterViewInit { + OnInit, DoCheck, OnDestroy, AfterContentInit, AfterViewInit, OnChanges { /* blazorSuppress */ public readonly validation = inject(IgxGridValidationService); @@ -196,6 +199,7 @@ export abstract class IgxGridBaseDirective implements GridType, * * ``` */ + @WatchChanges() @Input({ transform: booleanAttribute }) public autoGenerate = false; @@ -1796,6 +1800,7 @@ export abstract class IgxGridBaseDirective implements GridType, return this._rendered ? this._rowCount : null; } + /* treatAsRef */ /** * Gets/Sets the resource strings. * @@ -3696,7 +3701,12 @@ export abstract class IgxGridBaseDirective implements GridType, this.throttleTime$.pipe( take(1), switchMap(time => timer(time, this.throttleScheduler)) - ) + ), + // `trailing: true` ensures the final settle position of a fast momentum + // scroll is processed; otherwise the last scroll events are dropped and the + // rows stay frozen at an intermediate startIndex while the scrollbar is at top. + // `leading: true` keeps the immediate response on scroll start. + { leading: true, trailing: true } ), destructor ) @@ -4012,6 +4022,11 @@ export abstract class IgxGridBaseDirective implements GridType, } this.setupColumns(); + this.columnList.changes + .pipe(takeUntil(this.destroy$)) + .subscribe((change: QueryList) => { + this.onColumnsChanged(change); + }); this.toolbar.changes.pipe(filter(() => !this._init), takeUntil(this.destroy$)).subscribe(() => this.notifyChanges(true)); this.setUpPaginator(); this.paginationComponents.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { @@ -4165,9 +4180,7 @@ 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(() => { - 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 @@ -4248,6 +4261,16 @@ export abstract class IgxGridBaseDirective implements GridType, } } + /** + * @hidden @internal + */ + public ngOnChanges(changes: SimpleChanges) { + if (!changes.autoGenerate?.firstChange && changes.autoGenerate?.currentValue && this.data?.length > 0 && this.columnList?.length === 0 && this.columns.length === 0) { + // Make sure to setup columns only after the grid is initialized and autoGenerate is changed + this.setupColumns(); + } + } + /** * @hidden * @internal @@ -4680,7 +4703,7 @@ 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(() => { + runAfterRenderOnce(this.injector, () => { this.cdr.detectChanges(); this.autoSizeColumnsInView(); }); @@ -5636,6 +5659,16 @@ export abstract class IgxGridBaseDirective implements GridType, return '0px'; } + // When the grid has no measurable width, calculateGridWidth() falls back to the + // sum of its column widths and sets isColumnWidthSum. If all visible columns + // already have explicit or constrained widths, columnsToSize is 0 and + // computedWidth equals sumExistingWidths, resulting in 0 / 0 = NaN. + // Return the "0px" sentinel so _derivePossibleWidth() preserves the existing + // valid column widths. + if (columnsToSize <= 0 && this.isColumnWidthSum) { + return '0px'; + } + computedWidth -= this.featureColumnsWidth(); const columnWidth = !Number.isFinite(sumExistingWidths) ? @@ -6376,7 +6409,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(() => { + 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) { @@ -6763,7 +6796,7 @@ export abstract class IgxGridBaseDirective implements GridType, } else if (this.width !== null) { this._columnWidth = Math.max(parseFloat(possibleWidth), this.minColumnWidth) + 'px' } else { - this._columnWidth = this.minColumnWidth + 'px'; + this._columnWidth = this.minColumnWidth + 'px'; } } this._updateColumnDefaultWidths(); @@ -6896,12 +6929,6 @@ export abstract class IgxGridBaseDirective implements GridType, this.initColumns(this._columns, (col: IgxColumnComponent) => this.columnInit.emit(col)); this.columnListDiffer.diff(this.columnList); this._calculateRowCount(); - - this.columnList.changes - .pipe(takeUntil(this.destroy$)) - .subscribe((change: QueryList) => { - this.onColumnsChanged(change); - }); } protected getColumnList() { @@ -7075,24 +7102,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.zone.onStable.pipe(first()).subscribe(() => { - this._autoSizeColumnsNotify.next(); - }); + runAfterRenderOnce(this.injector, () => this._autoSizeColumnsNotify.next()); } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7586,6 +7605,9 @@ export abstract class IgxGridBaseDirective implements GridType, if (colResized) { this.resetCachedWidths(); this.cdr.detectChanges(); + // Rebuild master's sizesCache once from updated calcPixelWidth values + this.headerContainer.resolveDataDiff(); + this._horizontalForOfs.forEach(vfor => vfor.resolveDataDiff()); } if (this.isColumnWidthSum) { @@ -7742,19 +7764,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(); - callback(); - } else { - this.zone.onStable.pipe(first()).subscribe(callback); - } + runAfterRenderOnce(this.injector, callback); this.disableTransitions = false; this.hideOverlays(); @@ -7779,10 +7795,6 @@ export abstract class IgxGridBaseDirective implements GridType, this.gridScroll.emit(args); } - protected isZonelessChangeDetection(): boolean { - return this.zone.constructor.name === 'NoopNgZone'; - } - protected hasMenuPinningActions(): boolean { const strip = this.actionStrip; const actionButtons = strip?.actionButtons; @@ -7807,7 +7819,7 @@ export abstract class IgxGridBaseDirective implements GridType, this.cdr.markForCheck(); this.zone.run(() => { - this.zone.onStable.pipe(first()).subscribe(() => { + runAfterRenderOnce(this.injector, () => { this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); requestAnimationFrame(() => { this.autoSizeColumnsInView(); 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..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 @@ -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'; @@ -1444,12 +1445,12 @@ 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(); UIInteractions.simulateClickAndSelectEvent(firstCell); - await wait(); - fix.detectChanges(); + await fix.whenStable(); expect(selectionChangeSpy).toHaveBeenCalledTimes(0); GridSelectionFunctions.verifyCellSelected(firstCell); @@ -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 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-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts index ead0ee46145..7bd294cd3ce 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')); @@ -2584,8 +2585,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); @@ -4085,6 +4086,19 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { expect(listItems.length).toBe(6, 'incorrect rendered list items count'); }); + it('Should use the list content height for the virtual container size', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + await wait(100); + + const searchComponent = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + const listElement = searchComponent.list.element.nativeElement as HTMLElement; + listElement.style.border = '1px solid transparent'; + + expect(listElement.offsetHeight).toBeGreaterThan(listElement.clientHeight); + expect(searchComponent.containerSize).toBe(listElement.clientHeight); + }); + it('Should allow to input commas in excel search component input field when column dataType is number.', async () => { GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); fix.detectChanges(); 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..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(); @@ -477,33 +478,33 @@ 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(() => { + 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', () => { 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 327bdcab091..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 @@ -1727,6 +1727,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, @@ -1914,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', diff --git a/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts index 2e6ab931322..5e5c274860e 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts @@ -225,6 +225,7 @@ describe('IgxGrid - Validation #grid', () => { expect(cell.errorTooltip.first.collapsed).toBeTrue(); const element = fixture.debugElement.query(By.directive(IgxTooltipTargetDirective)).nativeElement; + spyOn(element, 'matches').and.returnValue(true); element.dispatchEvent(new MouseEvent('pointerenter')); flush(); fixture.detectChanges(); 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..6e00e09d4c5 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'; @@ -95,6 +95,25 @@ describe('IgxGrid Component Tests #grid', () => { expect(fix.componentInstance.columnEventCount).toEqual(4); }); + it('should initialize a grid with data and columns if autoGenerate is set after the data', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = [ + { Number: 1, String: '1', Boolean: true, Date: new Date(Date.now()) } + ]; + fix.componentInstance.columns = []; + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.columns.length).toBe(0); + + fix.componentInstance.autoGenerate = true; + fix.detectChanges(); + + expect(grid.columns.length).toBe(4); + expect(grid.rowList.length).toBe(1); + }); + it('should initialize a grid and change column properties during initialization', () => { const fix = TestBed.createComponent(IgxGridTestComponent); fix.componentInstance.columns = []; @@ -882,6 +901,73 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(2 * (initialHorScroll + 50)); })); + + describe('scroll throttle trailing edge', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxGridScrollThrottleComponent], + providers: [{ provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }] + }).compileComponents(); + })); + + // Drive scrollNotify directly (not programmatic scrollTop, whose async native events would mask a dropped-trailing regression) to exercise the throttle window deterministically. + it('should settle at the top row after a fast momentum scroll back to scrollTop 0', async () => { + const fix = TestBed.createComponent(IgxGridScrollThrottleComponent); + fix.detectChanges(); + await wait(50); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const virtDir = grid.verticalScrollContainer; + const scrollEl = virtDir.getScroll(); + const hScroll = grid.headerContainer.getScroll(); + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + + // Guard the reproduction condition: the fixture must overflow horizontally. + expect(hScroll.scrollWidth).toBeGreaterThan(hScroll.clientWidth); + + // Move away from the top so the first rows are virtualized out of view. + grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); + await wait(50); + fix.detectChanges(); + expect(virtDir.state.startIndex).toBeGreaterThan(0); + + // Momentum scroll back to top: intermediate on the leading edge, scrollTop = 0 settle only on the trailing edge. + grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); + grid.scrollNotify.next({ target: { scrollTop: 0 } }); + await wait(50); + fix.detectChanges(); + + // Without the trailing edge the settle is dropped and startIndex stays frozen mid-list. + expect(virtDir.state.startIndex).toBe(0); + expect(grid.gridAPI.get_row_by_index(0)).toBeDefined(); + }); + + it('should settle at the last row after a fast momentum scroll to the bottom', async () => { + const fix = TestBed.createComponent(IgxGridScrollThrottleComponent); + fix.detectChanges(); + await wait(50); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const virtDir = grid.verticalScrollContainer; + const scrollEl = virtDir.getScroll(); + const hScroll = grid.headerContainer.getScroll(); + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + const lastIndex = fix.componentInstance.data.length - 1; + + expect(hScroll.scrollWidth).toBeGreaterThan(hScroll.clientWidth); + expect(virtDir.state.startIndex).toBe(0); + + // Momentum scroll top to bottom: intermediate on the leading edge, max-scroll settle only on the trailing edge. + grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); + grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); + await wait(50); + fix.detectChanges(); + + // Without the trailing edge the settle is dropped and the last row is never brought into view. + expect((virtDir.state.startIndex ?? 0) + (virtDir.state.chunkSize ?? 0)).toBeGreaterThanOrEqual(lastIndex + 1); + expect(grid.gridAPI.get_row_by_index(lastIndex)).toBeDefined(); + }); + }); }); describe('IgxGrid - default rendering for rows and columns', () => { @@ -2089,6 +2175,7 @@ describe('IgxGrid Component Tests #grid', () => { const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); grid.navigateTo(50, 16); + await fix.whenStable(); fix.detectChanges(); await wait(100); fix.detectChanges(); @@ -2104,6 +2191,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', () => { @@ -3460,6 +3604,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: `
@@ -4051,3 +4231,19 @@ export class IgxGridPerformanceComponent implements AfterViewInit, OnInit { export class IgxGridNoDataComponent { @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; } + +@Component({ + template: ``, + imports: [IgxGridComponent] +}) +class IgxGridScrollThrottleComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + // 30 columns in a 600px-wide grid guarantee horizontal overflow, plus 200 rows for vertical virtualization. + public data = Array.from({ length: 200 }, (_row, rowIndex) => { + const record: Record = { ID: rowIndex }; + for (let col = 0; col < 30; col++) { + record[`Col${col}`] = `r${rowIndex}c${col}`; + } + return record; + }); +} 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/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts index 792ccbd5544..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 @@ -1,7 +1,8 @@ -import { Component, ViewChild, OnInit, DebugElement, QueryList, TemplateRef, ViewChildren, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, OnInit, DebugElement, QueryList, TemplateRef, ViewChildren, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; +import { firstValueFrom } from 'rxjs'; import { UIInteractions, wait, waitForActiveNodeChange } from '../../../test-utils/ui-interactions.spec'; import { IgxGridComponent } from './grid.component'; import { IgxGridRowComponent } from './grid-row.component'; @@ -664,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); @@ -1276,6 +1278,154 @@ 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(); + })); + + 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..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,6 +1073,7 @@ describe('IgxGrid - search API #grid', () => { }); it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { + fix.autoDetectChanges(); grid.height = '500px'; fix.detectChanges(); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts index 3c04305b18f..51def59d8f1 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts @@ -4,8 +4,10 @@ import { IgxRowIslandComponent } from './row-island.component'; export interface IGridCreatedEventArgs extends IBaseEventArgs { + /* alternateType: RowIsland */ owner: IgxRowIslandComponent; parentID: any; + /* alternateType: HierarchicalGrid */ grid: IgxHierarchicalGridComponent; parentRowData?: any; } diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html index 058e9f9c040..218e0cba312 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html @@ -129,10 +129,12 @@ }
-
- @if (shouldOverlayLoading) { - - } +
+
+ @if (shouldOverlayLoading) { + + } +
@if (moving && columnInDrag) { { }); 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); @@ -157,7 +159,8 @@ 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 firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + // verify selection in child. const selectedCell = fixture.componentInstance.selectedCell; @@ -167,9 +170,11 @@ 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 () => { + fixture.autoDetectChanges(); hierarchicalGrid.verticalScrollContainer.scrollTo(2); fixture.detectChanges(); await wait(DEBOUNCE_TIME); @@ -365,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); @@ -506,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(); @@ -665,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(); @@ -712,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]; @@ -734,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(); @@ -779,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(); @@ -827,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); @@ -860,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); @@ -917,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(); @@ -939,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; @@ -1001,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'; @@ -1018,8 +1034,8 @@ describe('IgxHierarchicalGrid Navigation', () => { rowID: 5 }; - hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested]); - await wait(DEBOUNCE_TIME * 2); + await new Promise(resolve => + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], resolve)); fixture.detectChanges(); const childGrid = hierarchicalGrid.gridAPI.getChildGrid([targetRoot]).nativeElement; expect(childGrid).not.toBe(undefined); @@ -1032,6 +1048,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/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts index 6575d4812e9..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 @@ -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(); // Expand two rows at the top (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts index a9afd73559a..ce728f28fef 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts @@ -81,6 +81,7 @@ export class IgxRowIslandComponent extends IgxHierarchicalGridBaseDirective @Input() public key: string; + /* treatAsRef */ /** * Gets/Sets the resource strings. * @@ -93,7 +94,7 @@ export class IgxRowIslandComponent extends IgxHierarchicalGridBaseDirective this.updateGridsResources(); } - public override get resourceStrings() { + public override get resourceStrings(): IGridResourceStrings { return super.resourceStrings ?? this.rootGrid.resourceStrings; } @@ -431,7 +432,7 @@ export class IgxRowIslandComponent extends IgxHierarchicalGridBaseDirective /** * @hidden */ - public ngOnChanges(changes) { + public override ngOnChanges(changes) { this.layoutChange.emit(changes); if (!this.isInit) { this.initialChanges.push(changes); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html index 34c19e6198d..69dbf5ec03a 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html @@ -63,10 +63,12 @@
-
- @if (shouldOverlayLoading) { - - } +
+
+ @if (shouldOverlayLoading) { + + } +
@if (hasMovableColumns && columnInDrag) { { + this.setDateDimensionsLocaleData(); // Since the columns are kinda static, due to assigning DisplayName on init, they need to be regenerated. - this.setupColumns(); + Promise.resolve().then(() => { + this.setupColumns(); + }); }, this); }); if (this.valueChipTemplateDirective) { @@ -1020,7 +1024,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni /** * @hidden @internal */ - public ngOnChanges(changes: SimpleChanges) { + public override ngOnChanges(changes: SimpleChanges) { if (changes.superCompactMode && !changes.superCompactMode.isFirstChange()) { this._shouldUpdateSizes = true; resizeObservable(this.verticalScrollContainer.displayContainer).pipe(take(1), takeUntil(this.destroy$)).subscribe(() => this.resizeNotify.next()); @@ -1626,6 +1630,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni * This parameter is optional. If not set it will add it to the end of the collection. */ public insertDimensionAt(dimension: IPivotDimension, targetCollectionType: PivotDimensionType, index?: number) { + this.setDateDimensionsLocaleData([dimension]); const targetCollection = this.getDimensionsByType(targetCollectionType); if (index !== undefined) { targetCollection.splice(index, 0, dimension); @@ -2153,7 +2158,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + runAfterRenderOnce(this.injector, () => { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); @@ -2487,6 +2492,37 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni protected trackHorizontalRowGroup = (_index: number, rowGroup: IPivotGridRecord[]) => rowGroup[0]?.dataIndex; + /** + * Sets the locale and resourceStrings data based on the grid's properties for all IgxPivotDateDimensions in the config. + * By default search all dimensions (even not enabled). + * @param entryDimensions Entry dimension on which to check for IgxPivotDateDimension instead of the default. + */ + protected setDateDimensionsLocaleData(entryDimensions?: IPivotDimension[]) { + const topDimensions = entryDimensions ?? [...this.allDimensions]; + for (const dim of topDimensions) { + let foundDateDim: IgxPivotDateDimension | undefined; + if (dim instanceof IgxPivotDateDimension) { + foundDateDim = dim; + } else if (dim.childLevel) { + var curChild: IPivotDimension | undefined = dim.childLevel; + while(curChild) { + if (curChild instanceof IgxPivotDateDimension) { + foundDateDim = curChild; + break; + } + curChild = curChild.childLevel; + } + } + + if (foundDateDim) { + foundDateDim.resourceStrings = this.resourceStrings; + if (this.locale) { + foundDateDim.locale = this.locale; + } + } + } + } + /** * @hidden @internal */ 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..c86a767c90b 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 @@ -21,7 +21,6 @@ import { IgxPivotRowDimensionContentComponent } from './pivot-row-dimension-cont import { IgxPivotGridComponent } from './pivot-grid.component'; import { IgxGridCell } from 'igniteui-angular/grids/core'; import { IGridCellEventArgs } from 'igniteui-angular/grids/core'; -import { getI18nManager } from 'igniteui-i18n-core'; const CSS_CLASS_LIST = 'igx-drop-down__list'; const CSS_CLASS_ITEM = 'igx-drop-down__item'; @@ -843,9 +842,6 @@ describe('IgxPivotGrid #pivotGrid', () => { describe('IgxPivotGrid Features #pivotGrid', () => { it('should show excel style filtering via dimension chip.', async () => { - // Weird angular error caused by calling setupColumns() on opening a filter. Disable it for now. - (getI18nManager() as any).removeAllListeners(); - const pivotGrid = fixture.componentInstance.pivotGrid; expect(pivotGrid.filterStrategy).toBeInstanceOf(DimensionValuesFilteringStrategy); const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; @@ -879,9 +875,6 @@ describe('IgxPivotGrid #pivotGrid', () => { }); it('should filter rows via excel style filtering dimension chip.', async () => { - // Weird angular error caused by calling setupColumns() on opening a filter. Disable it for now. - (getI18nManager() as any).removeAllListeners(); - const pivotGrid = fixture.componentInstance.pivotGrid; const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); const rowChip = headerRow.querySelector('igx-chip[id="All"]'); @@ -1126,9 +1119,6 @@ describe('IgxPivotGrid #pivotGrid', () => { }); it('should show complex tree and allow filtering for Date dimension', async () => { - // Weird angular error caused by calling setupColumns() on opening a filter. Disable it for now. - (getI18nManager() as any).removeAllListeners(); - const pivotGrid = fixture.componentInstance.pivotGrid; pivotGrid.pivotConfiguration.rows = [new IgxPivotDateDimension( { 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..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 @@ -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'; @@ -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; @@ -847,3 +850,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/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts index 71d8f5bb4ae..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 @@ -172,14 +172,14 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { treeGrid.summaryCalculationMode = 'rootLevelOnly'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); verifyTreeBaseSummaries(fix); expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); treeGrid.summaryCalculationMode = 'childLevelsOnly'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); @@ -188,7 +188,7 @@ describe('IgxTreeGrid - Summaries #tGrid', () => { treeGrid.summaryCalculationMode = 'rootAndChildLevels'; fix.detectChanges(); - await wait(50); + await fix.whenStable(); verifyTreeBaseSummaries(fix); expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); @@ -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/grids/tree-grid/src/tree-grid.component.html b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html index 69fecf903c5..d55a1cceba6 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html @@ -330,10 +330,12 @@
-
- @if (shouldOverlayLoading) { - - } +
+
+ @if (shouldOverlayLoading) { + + } +
@if (moving && columnInDrag) { { // 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 +241,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 = []; diff --git a/projects/igniteui-angular/migrations/README.md b/projects/igniteui-angular/migrations/README.md new file mode 100644 index 00000000000..c20d93c8276 --- /dev/null +++ b/projects/igniteui-angular/migrations/README.md @@ -0,0 +1,79 @@ +# Ignite UI for Angular — `ng update` Migrations + +This directory hosts the migration schematics that run when consumers execute +`ng update igniteui-angular`. Each `update-*` folder is a versioned migration +listed in [`migration-collection.json`](./migration-collection.json) and +implemented in `update-*/index.ts`. Shared helpers live in +[`common/`](./common). + +See [Wiki pages under Migrations & Schematics](https://github.com/IgniteUI/igniteui-angular/wiki#migrations--schematics) +for authoring and testing guidance. + +## Encapsulation and external ESM like `@angular/compiler` +The collection has `"encapsulation": true` so every +migration is loaded in its own isolated VM context by the Angular +schematics tooling. That also restricts some API access/functionality. + +See the description of [#13712 — fix(migrations,ng-add): turn on encapsulation for devkit/schematics deps](https://github.com/IgniteUI/igniteui-angular/pull/13712) when encapsulation was first enabled for mechanism details. + +### Dynamic `@angular/compiler` import via `nativeImport` + +`@angular/compiler` is an ES module. The migrations themselves are emitted and consumed +(`require()`-d) by the Angular schematics as CommonJS, so they cannot statically `import` it; +they have to use a runtime dynamic `import()`. + +- [`common/import-helper.cts`](./common/import-helper.cts) — authored as + a TypeScript CJS module. The dynamic `import()` is preserved in the emitted. +- Migrations call it via: + ```ts + import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; + // ... + const { HtmlParser } = await nativeImport('@angular/compiler'); + ``` + +### Why the bare `igniteui-angular/...` specifier + +Schematics encapsulation being a VM execution is affected by the +[Support of dynamic import() in compilation APIs](https://nodejs.org/api/vm.html#support-of-dynamic-import-in-compilation-apis) +and at this time Angular schematics do not implement [importModuleDynamically](https://github.com/angular/angular-cli/blob/e5eb3e37c9756669b3b91b5b681a73cc9b616cb9/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts#L50). + +A **bare package specifier** effectively passes through the schematics host encapsulation again and escapes [the relative wrapping ](https://github.com/angular/angular-cli/blob/e5eb3e37c9756669b3b91b5b681a73cc9b616cb9/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts#L177-L178) out into [the original schematic require](https://github.com/angular/angular-cli/blob/e5eb3e37c9756669b3b91b5b681a73cc9b616cb9/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts#L203-L204). +This means just the `import-helper.cjs` script is ran outside of encapsulation and as such can perform dynamic imports. + +Every migration that needs `@angular/compiler` follows the same pattern. + +To keep TypeScript happy with the bare specifier at compile time, +[`tsconfig.json`](./tsconfig.json) maps it back to the source: + +```jsonc +"paths": { + "igniteui-angular/*": ["../*"], + "@infragistics/igniteui-angular/*": ["../*"] +} +``` + +At consumer install time the specifier resolves naturally through their +`node_modules/igniteui-angular/migrations/common/import-helper.cjs`. + +### Tests setup for the bare specifier + +`npm run test:schematics` compiles the migrations with +[`tsconfig.spec.json`](./tsconfig.spec.json) into `dist/igniteui-angular/...` +and runs the compiled `*.spec.js` files through Jasmine. In this repo there is +no `node_modules/igniteui-angular` (the library is the workspace itself), so +the bare `igniteui-angular/migrations/common/import-helper.cjs` specifier +would not resolve at runtime. + +[`tsconfig-paths-bootstrap.js`](./tsconfig-paths-bootstrap.js) fixes that. It +is loaded with `node --require` before Jasmine starts and installs a runtime +path map (via the `tsconfig-paths` package) that points +`igniteui-angular/*` at `./dist/igniteui-angular/*`. + +The wiring in [`package.json`](../../../package.json) looks like: + +```text +node -r ./projects/igniteui-angular/migrations/tsconfig-paths-bootstrap.js \ + ./node_modules/jasmine/bin/jasmine.js \ + "dist/igniteui-angular/migrations/**/*.spec.js" \ + "dist/igniteui-angular/schematics/**/*.spec.js" +``` diff --git a/projects/igniteui-angular/migrations/common/import-helper.cts b/projects/igniteui-angular/migrations/common/import-helper.cts new file mode 100644 index 00000000000..8248043a059 --- /dev/null +++ b/projects/igniteui-angular/migrations/common/import-helper.cts @@ -0,0 +1,15 @@ +/** Explicit module map, as generic T can't be resolved to a direct string literal via inference */ +type ModuleMap = { + '@angular/compiler': typeof import('@angular/compiler', { with: { "resolution-mode": "import" }}); +}; + +/** + * Native Node dynamic import helper + * @remarks + * NB: Import this via a bare specifier (igniteui-angular/migrations/common/import-helper.cjs) + * to escape the schematics encapsulation for this file and thus the dynamic import inside. + * This allows to dynamically import ESM modules from outside the schematics host context. + */ +export function nativeImport(name: T): Promise { + return import(name); +}; diff --git a/projects/igniteui-angular/migrations/tsconfig-paths-bootstrap.js b/projects/igniteui-angular/migrations/tsconfig-paths-bootstrap.js new file mode 100644 index 00000000000..00e8711c5ed --- /dev/null +++ b/projects/igniteui-angular/migrations/tsconfig-paths-bootstrap.js @@ -0,0 +1,23 @@ +/** + * Test-time bootstrap. Registered via `node --require` before jasmine runs so + * the `igniteui-angular/...` bare specifier used by migrations (to escape the + * schematic encapsulation for the dynamic import helper) resolves against the + * built migrations in dist instead of `node_modules` (which has no + * igniteui-angular install in this repo). + * + * The compile-time path map in ./tsconfig.json points at the sources under + * ../*; at runtime we point at the compiled output under + * /dist/igniteui-angular/* so the require resolves to the emitted .cjs. + */ +const path = require("path"); +const tsConfigPaths = require("tsconfig-paths"); + +const distRoot = path.resolve(__dirname, "../../../dist/igniteui-angular"); + +tsConfigPaths.register({ + baseUrl: distRoot, + paths: { + "igniteui-angular/*": ["./*"], + "@infragistics/igniteui-angular/*": ["./*"], + }, +}); diff --git a/projects/igniteui-angular/migrations/update-11_0_0/index.ts b/projects/igniteui-angular/migrations/update-11_0_0/index.ts index 27e33d97280..8c14f28bac8 100644 --- a/projects/igniteui-angular/migrations/update-11_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-11_0_0/index.ts @@ -2,6 +2,8 @@ import type { Element } from '@angular/compiler' with { "resolution-mode": "impo import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile, serializeNodes } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; const version = '11.0.0'; @@ -9,8 +11,7 @@ export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info( `Applying migration for Ignite UI for Angular to version ${version}` ); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser, getHtmlTagDefinition } = await import('@angular/compiler'); + const { HtmlParser, getHtmlTagDefinition } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); diff --git a/projects/igniteui-angular/migrations/update-12_0_0/index.ts b/projects/igniteui-angular/migrations/update-12_0_0/index.ts index dbdaac7b753..6ffba314e45 100644 --- a/projects/igniteui-angular/migrations/update-12_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-12_0_0/index.ts @@ -2,6 +2,8 @@ import type { Element } from '@angular/compiler' with { "resolution-mode": "impo import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, getAttribute, findElementNodes, getSourceOffset, hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import type { Options } from '../../schematics/interfaces/options'; const version = '12.0.0'; @@ -12,8 +14,7 @@ export default (options: Options): Rule => `Applying migration for Ignite UI for Angular to version ${version}` ); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); // eslint-disable-next-line max-len const UPDATE_NOTE = `\n`; diff --git a/projects/igniteui-angular/migrations/update-12_1_0/index.ts b/projects/igniteui-angular/migrations/update-12_1_0/index.ts index 78258a4d350..0b841b759ef 100644 --- a/projects/igniteui-angular/migrations/update-12_1_0/index.ts +++ b/projects/igniteui-angular/migrations/update-12_1_0/index.ts @@ -5,14 +5,15 @@ import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile, serializeNodes, makeNgIf, stringifyAttributes } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; const version = '12.1.0'; export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser, getHtmlTagDefinition } = await import('@angular/compiler'); + const { HtmlParser, getHtmlTagDefinition } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const TAGS = ['igx-grid', 'igx-tree-grid', 'igx-hierarchical-grid']; diff --git a/projects/igniteui-angular/migrations/update-13_0_0/index.ts b/projects/igniteui-angular/migrations/update-13_0_0/index.ts index 2e7f07efd7f..186d2b6e729 100644 --- a/projects/igniteui-angular/migrations/update-13_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-13_0_0/index.ts @@ -7,6 +7,8 @@ import type { import type { Options } from '../../schematics/interfaces/options'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; const version = '13.0.0'; @@ -25,8 +27,7 @@ export default (options: Options): Rule => '[exportExcel]', 'exportExcel', '[exportExcelText]', 'exportExcelText', '[exportCsv]', 'exportCsv', '[exportCsvText]', 'exportCsvText', '[exportText]', 'exportText']; const actionsLeft = ['igx-grid-toolbar-advanced-filtering']; - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const moduleTsFiles = tsFiles.filter(x => x.endsWith('.module.ts')); for (const path of moduleTsFiles) { diff --git a/projects/igniteui-angular/migrations/update-13_1_0/index.ts b/projects/igniteui-angular/migrations/update-13_1_0/index.ts index d6722d4d6d7..e1cc333ca54 100644 --- a/projects/igniteui-angular/migrations/update-13_1_0/index.ts +++ b/projects/igniteui-angular/migrations/update-13_1_0/index.ts @@ -4,6 +4,8 @@ import type { Tree } from '@angular-devkit/schematics'; import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile } from '../common/util'; @@ -11,8 +13,7 @@ const version = '13.1.0'; export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const GRID_TAGS = ['igx-grid', 'igx-tree-grid', 'igx-hierarchical-grid', 'igx-row-island']; diff --git a/projects/igniteui-angular/migrations/update-15_1_0/index.ts b/projects/igniteui-angular/migrations/update-15_1_0/index.ts index 656873aa075..e4eea45360e 100644 --- a/projects/igniteui-angular/migrations/update-15_1_0/index.ts +++ b/projects/igniteui-angular/migrations/update-15_1_0/index.ts @@ -4,6 +4,8 @@ import type { Tree } from '@angular-devkit/schematics'; import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import type { Options } from '../../schematics/interfaces/options'; import { BoundPropertyObject, InputPropertyType, UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, parseFile, hasAttribute } from '../common/util'; @@ -13,8 +15,7 @@ const version = '15.1.0'; export default (options: Options): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const cardsToMigrate = new Set(); const CARD_ACTIONS = ['igx-card-actions']; diff --git a/projects/igniteui-angular/migrations/update-16_1_0/index.ts b/projects/igniteui-angular/migrations/update-16_1_0/index.ts index 3c7bc7a977d..e7740435ce0 100644 --- a/projects/igniteui-angular/migrations/update-16_1_0/index.ts +++ b/projects/igniteui-angular/migrations/update-16_1_0/index.ts @@ -5,14 +5,15 @@ import type { } from '@angular-devkit/schematics'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; const version = '16.1.0'; export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); update.applyChanges(); diff --git a/projects/igniteui-angular/migrations/update-17_0_0/index.ts b/projects/igniteui-angular/migrations/update-17_0_0/index.ts index c1f9f8133d9..a48dffd2fe3 100644 --- a/projects/igniteui-angular/migrations/update-17_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-17_0_0/index.ts @@ -6,6 +6,8 @@ import type { import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; import * as ts from 'typescript'; import { UpdateChanges } from '../common/UpdateChanges'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import { igNamedImportFilter } from '../common/tsUtils'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile } from '../common/util'; @@ -13,8 +15,7 @@ const version = '17.0.0'; export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const changes = new Map(); const prop = ['type']; diff --git a/projects/igniteui-angular/migrations/update-17_1_0/index.ts b/projects/igniteui-angular/migrations/update-17_1_0/index.ts index 73355aabc16..394da3864cd 100644 --- a/projects/igniteui-angular/migrations/update-17_1_0/index.ts +++ b/projects/igniteui-angular/migrations/update-17_1_0/index.ts @@ -5,14 +5,15 @@ import { } from '@angular-devkit/schematics'; import { UpdateChanges } from '../common/UpdateChanges'; import { FileChange, findElementNodes, getAttribute, getSourceOffset, hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; const version = '17.1.0'; export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const changes = new Map(); const tags = ['button', 'span', 'a', 'div', 'igx-prefix', 'igx-suffix'] diff --git a/projects/igniteui-angular/migrations/update-18_0_0/index.ts b/projects/igniteui-angular/migrations/update-18_0_0/index.ts index f477f2cafef..88343b6a644 100644 --- a/projects/igniteui-angular/migrations/update-18_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-18_0_0/index.ts @@ -1,5 +1,7 @@ import { Rule, SchematicContext, SchematicsException, Tree } from "@angular-devkit/schematics"; import { FileChange, findElementNodes, getAttribute, getProjects, getSourceOffset, getWorkspace, hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; import type { Element } from '@angular/compiler' with { "resolution-mode": "import" }; import { BoundPropertyObject, InputPropertyType, UpdateChanges } from "../common/UpdateChanges"; @@ -9,8 +11,7 @@ export default (): Rule => async (host: Tree, context: SchematicContext) => { context.logger.info( `Applying migration for Ignite UI for Angular to version ${version}`, ); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser } = await import('@angular/compiler'); + const { HtmlParser } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const changes = new Map(); const prop = ["displayDensity", "[displayDensity]"]; diff --git a/projects/igniteui-angular/migrations/update-20_0_6/index.ts b/projects/igniteui-angular/migrations/update-20_0_6/index.ts index 7d31dc8b828..53b33854207 100644 --- a/projects/igniteui-angular/migrations/update-20_0_6/index.ts +++ b/projects/igniteui-angular/migrations/update-20_0_6/index.ts @@ -6,6 +6,8 @@ import { getSourceOffset, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; const version = '20.0.6'; @@ -14,8 +16,7 @@ export default (): Rule => async (host: Tree, context: SchematicContext) => { `Applying migration for Ignite UI for Angular to version ${version}` ); - // bare specifier escapes schematics encapsulation for the compiler dynamic import: - const { HtmlParser, Element } = await import('@angular/compiler'); + const { HtmlParser, Element } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const changes = new Map(); diff --git a/projects/igniteui-angular/migrations/update-22_0_0/index.ts b/projects/igniteui-angular/migrations/update-22_0_0/index.ts index 8dc0ce3688d..1d4fe0f8756 100644 --- a/projects/igniteui-angular/migrations/update-22_0_0/index.ts +++ b/projects/igniteui-angular/migrations/update-22_0_0/index.ts @@ -7,6 +7,8 @@ import { hasAttribute, parseFile } from '../common/util'; +// use bare specifier to escape the schematics encapsulation for the dynamic import: +import { nativeImport } from 'igniteui-angular/migrations/common/import-helper.cjs'; const version = '22.0.0'; @@ -15,7 +17,7 @@ export default (): Rule => async (host: Tree, context: SchematicContext) => { `Applying migration for Ignite UI for Angular to version ${version}` ); - const { HtmlParser, Element } = await import('@angular/compiler'); + const { HtmlParser, Element } = await nativeImport('@angular/compiler'); const update = new UpdateChanges(__dirname, host, context); const changes = new Map(); diff --git a/projects/igniteui-angular/package.json b/projects/igniteui-angular/package.json index 44f1597ef4a..2696455c910 100644 --- a/projects/igniteui-angular/package.json +++ b/projects/igniteui-angular/package.json @@ -87,7 +87,7 @@ "hammerjs": "^2.0.8", "@types/hammerjs": "^2.0.46", "igniteui-webcomponents": "^7.2.1", - "igniteui-grid-lite": "~0.7.1" + "igniteui-grid-lite": "~0.9.0" }, "peerDependenciesMeta": { "hammerjs": { diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts b/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts index 90baf2a3627..77031292301 100644 --- a/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts +++ b/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts @@ -31,7 +31,8 @@ export class IgxPaginatorContentDirective { /* wcElementTag: igc-paginator */ /* blazorIndirectRender */ /* singleInstanceIdentifier */ -/* contentParent: GridBaseDirective */ +/* contentParent: Grid */ +/* contentParent: TreeGrid */ /* contentParent: RowIsland */ /* contentParent: HierarchicalGrid */ /* jsonAPIManageCollectionInMarkup */ 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: `