diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ee8b113f9..8cb96b2cf9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ All notable changes for each version of this project will be documented in this - Touch pans no longer leave slides partially moved or visible behind the current slide, for example when a pan is cancelled, reversed past the first or last slide of a non-looping carousel, released as a tap, or interrupted by removing slides or changing `gesturesSupport` or `vertical`. - Panning a carousel without slides no longer throws an error, and panning a carousel with a single slide no longer moves the slide against itself. - A slide activated through its `active` property now animates in the direction of its position, or in its own `direction` when one is set. +- `IgxTreeGridComponent` + - Setting `data` before the grid is initialized while `autoGenerate` is enabled no longer throws an error. In Ignite UI for Web Components this happened when `data` was set on `igc-tree-grid` before the element was attached to the page. +- Ignite UI for Web Components grids + - Writable properties that are not inputs, such as `selected` of `igc-column` and `totalPages` of `igc-paginator`, are now applied when set on the element. Previously the value was ignored and an `NG0303` error was logged. + - The deprecated `getFilterFactory` method is now available on `igc-grid`, as intended for backwards compatibility. + - Icon changes broadcast from other pages now update only the icons or icon references that match the message's action type. - `IgxNavigationToggleDirective`, `IgxNavigationCloseDirective` - The `igxNavToggle` and `igxNavClose` inputs are now public, so they can be set in applications compiled with `strictInputAccessModifiers`. Previously the inputs were private and setting them failed the template type check. - `IgxStepperComponent` diff --git a/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts index 1cbc3feab70..806d3e74c0b 100644 --- a/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts @@ -14,6 +14,7 @@ import { ComponentConfig } from './component-config'; import { createIgxCustomElement, withRegister } from './create-custom-element'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; import { injector } from '../utils/injector-ref'; +import { IgcGridComponent, IgcTreeGridComponent } from './components'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @Component({ @@ -100,15 +101,31 @@ describe('createIgxCustomElement', () => { it('should only allow setting writable additional properties', async () => { const instance = await getInstance(); - const setInputSpy = vi.spyOn(element.ngElementStrategy, 'setInputValue').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error'); element.writableProp = 'updated'; - expect(setInputSpy).toHaveBeenCalledExactlyOnceWith('writableProp', 'updated'); + expect(instance.writableProp).toBe('updated'); + expect(element.writableProp).toBe('updated'); + expect(errorSpy).not.toHaveBeenCalled(); expect(() => (element as any).readonlyProp = 'nope').toThrow(TypeError); expect(instance.readonlyProp).toBe('readonly'); }); + it('should apply writable additional properties set before the element is connected', async () => { + const otherElement = document.createElement(TEST_TAG) as IgcNgElement & CustomElementTestComponent; + otherElement.writableProp = 'initial'; + expect(otherElement.writableProp).toBe('initial'); + + document.body.appendChild(otherElement); + try { + const instance = (await otherElement.ngElementStrategy[ComponentRefKey]).instance as CustomElementTestComponent; + expect(instance.writableProp).toBe('initial'); + } finally { + otherElement.remove(); + } + }); + it('should throw for additional properties that clash with the element prototype', () => { const config = createConfig({ additionalProperties: [{ name: 'connectedCallback' }] }); @@ -143,6 +160,12 @@ describe('createIgxCustomElement', () => { expect(tree.operator).toBe(FilteringLogic.Or); expect(tree.fieldName).toBe('Name'); }); + + it('should add the deprecated getFilterFactory to the igc-grid element', () => { + expect(IgcGridComponent.prototype.getFilterFactory).toBeTypeOf('function'); + expect(IgcGridComponent.prototype.getFilterFactory().stringFilteringOperand).toBe(IgxStringFilteringOperand.instance()); + expect(IgcTreeGridComponent.prototype.getFilterFactory).toBeUndefined(); + }); }); describe('withRegister', () => { diff --git a/projects/igniteui-angular-elements/src/app/create-custom-element.ts b/projects/igniteui-angular-elements/src/app/create-custom-element.ts index 9e7c53d009d..2c5ceb60d1f 100644 --- a/projects/igniteui-angular-elements/src/app/create-custom-element.ts +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.ts @@ -61,7 +61,8 @@ export function createIgxCustomElement(component: Type, config: IgxNgEleme }); }); - if (component === IgxGridComponent) { + // Elements register their own grid that extends the base one: + if (component === IgxGridComponent || component.prototype instanceof IgxGridComponent) { /** * @deprecated API access workaround moved from Grid for possible existing use compat. Remove in future version */ 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 13ce4cde1d6..9c56d17f7c0 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,7 +1,7 @@ import { ApplicationRef, ViewContainerRef } from '@angular/core'; -import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; +import { IgxActionStripComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; import { html } from 'lit'; -import { firstValueFrom, fromEvent, timer } from 'rxjs'; +import { filter, firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; import { injector } from '../utils/injector-ref'; import hgridData from '../assets/data/projects-hgrid.js'; @@ -12,6 +12,7 @@ import { IgcRowIslandComponent, IgcPivotGridComponent, IgcColumnComponent, + IgcColumnGroupComponent, IgcPaginatorComponent, IgcGridStateComponent, IgcColumnLayoutComponent, @@ -40,6 +41,7 @@ describe('Elements: ', () => { IgcPivotGridComponent, IgcPivotDataSelectorComponent, IgcColumnComponent, + IgcColumnGroupComponent, IgcColumnLayoutComponent, IgcPaginatorComponent, IgcGridStateComponent, @@ -227,7 +229,7 @@ describe('Elements: ', () => { gridEl.data = SampleTestData.foodProductData(); gridEl.addEventListener("columnInit", (args: CustomEvent) => { - args.detail.headerTemplate = (ctx) => html `Templated ${args.detail.field}`; + args.detail.headerTemplate = (_ctx) => html `Templated ${args.detail.field}`; }); testContainer.appendChild(gridEl); @@ -590,12 +592,12 @@ describe('Elements: ', () => { treeGridEl.autoGenerate = true; treeGridEl.primaryKey = 'ID'; treeGridEl.childDataKey = 'Employees'; + // data set before the element is connected is applied before the grid is initialized + treeGridEl.data = SampleTestData.employeeSmallTreeData(); + const autoGenerated = firstValueFrom(fromEvent(treeGridEl, 'columnsAutogenerated') + .pipe(filter(e => e.detail.columns.length > 0))); testContainer.appendChild(treeGridEl); - await treeGridEl.ngElementStrategy[ComponentRefKey]; - const autoGenerated = firstValueFrom(fromEvent(treeGridEl, 'columnsAutogenerated')); - // TODO: setting data before the tree grid is initialized throws with autoGenerate - treeGridEl.data = SampleTestData.employeeSmallTreeData(); const columns = (await autoGenerated).detail.columns as IgxColumnComponent[]; expect(columns.map(c => c.field)).toEqual(['ID', 'Name', 'HireDate', 'Age']); @@ -686,6 +688,237 @@ describe('Elements: ', () => { expect(column.rowStart).toBe('not a number'); }); + it('should set writable non-input properties on the component', async () => { + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.setAttribute('field', 'ProductID'); + gridEl.appendChild(columnEl); + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + column.selectable = true; + // wait for the column to be added to the grid's columns + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + + columnEl.selected = true; + + expect(column.selected).toBe(true); + expect(columnEl.selected).toBe(true); + expect(grid.selectedColumns()).toEqual([column]); + }); + + it('should apply cached inputs before writable non-input properties set before connect', async () => { + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + // dependent non-input property cached before the input it relies on: + columnEl.selected = true; + columnEl.field = 'ProductID'; + gridEl.appendChild(columnEl); + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + + expect(column.field).toBe('ProductID'); + expect(column.selected).toBe(true); + expect(columnEl.selected).toBe(true); + expect(grid.selectedColumns()).toEqual([column]); + }); + + it('should apply column group selection set before connect once its child columns resolve', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + groupEl.selected = true; + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + gridEl.appendChild(groupEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(group.selected).toBe(true); + expect(groupEl.selected).toBe(true); + }); + + it('should apply column group selection set after connect but before its child columns resolve', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + gridEl.appendChild(groupEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + expect(group.children.length).toBe(0); + groupEl.selected = true; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(group.selected).toBe(true); + }); + + it('should keep a pre-connect group selection repeated before its child columns resolve', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + groupEl.selected = true; + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + gridEl.appendChild(groupEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + groupEl.selected = true; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(group.selected).toBe(true); + }); + + it('should not re-apply a pre-connect group selection overridden before its child columns resolve', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + groupEl.selected = true; + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + gridEl.appendChild(groupEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + groupEl.selected = false; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(false); + expect(group.selected).toBe(false); + }); + + it('should apply a pre-connect selection of a group containing another group once all descendants resolve', async () => { + const outerGroupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + outerGroupEl.selected = true; + const innerGroupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + innerGroupEl.appendChild(columnEl); + outerGroupEl.appendChild(innerGroupEl); + gridEl.appendChild(outerGroupEl); + + const outerGroup = (await outerGroupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const innerGroup = (await innerGroupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(outerGroup.children.toArray()).toEqual([innerGroup]); + expect(innerGroup.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(outerGroup.selected).toBe(true); + }); + + it('should apply a pre-connect selection of an empty group once columns appended after connect resolve', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + groupEl.selected = true; + gridEl.appendChild(groupEl); + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(group.selected).toBe(true); + }); + + it('should apply a selection of a resolved empty group set while newly appended columns initialize', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + gridEl.appendChild(groupEl); + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(group.children.length).toBe(0); + + const columnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl.field = 'ProductID'; + groupEl.appendChild(columnEl); + groupEl.selected = true; + + const column = (await columnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(group.children.toArray()).toEqual([column]); + expect(column.selected).toBe(true); + expect(group.selected).toBe(true); + expect(grid.selectedColumns()).toEqual([column]); + }); + + it('should deselect a partially selected column group', async () => { + const groupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + const columnEl1 = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl1.field = 'ProductID'; + const columnEl2 = document.createElement('igc-column') as IgcNgElement & InstanceType; + columnEl2.field = 'ProductName'; + groupEl.append(columnEl1, columnEl2); + gridEl.appendChild(groupEl); + + const group = (await groupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const column1 = (await columnEl1.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + const column2 = (await columnEl2.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + column1.selected = true; + expect(group.children.toArray()).toEqual([column1, column2]); + expect(groupEl.selected).toBe(false); + + groupEl.selected = false; + + expect(column1.selected).toBe(false); + expect(column2.selected).toBe(false); + expect(grid.selectedColumns()).toEqual([]); + }); + + it('should apply a group selection set while a nested group is still resolving its child columns', async () => { + const outerGroupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + const leafColumnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + leafColumnEl.field = 'ProductID'; + const innerGroupEl = document.createElement('igc-column-group') as IgcNgElement & InstanceType; + const innerColumnEl = document.createElement('igc-column') as IgcNgElement & InstanceType; + innerColumnEl.field = 'ProductName'; + innerGroupEl.appendChild(innerColumnEl); + outerGroupEl.append(leafColumnEl, innerGroupEl); + + // hold back the nested group's query update to keep the hierarchy partially resolved: + const innerStrategy = innerGroupEl.ngElementStrategy; + const scheduleQueryUpdate = innerStrategy.scheduleQueryUpdate.bind(innerStrategy); + const pendingInnerUpdates: string[] = []; + const scheduleSpy = vi.spyOn(innerStrategy, 'scheduleQueryUpdate').mockImplementation((query: string) => { + pendingInnerUpdates.push(query); + }); + gridEl.appendChild(outerGroupEl); + + const outerGroup = (await outerGroupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const innerGroup = (await innerGroupEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnGroupComponent; + const leafColumn = (await leafColumnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + const innerColumn = (await innerColumnEl.ngElementStrategy[ComponentRefKey]).instance as IgxColumnComponent; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(outerGroup.children.toArray()).toEqual([leafColumn, innerGroup]); + expect(innerGroup.children.length).toBe(0); + expect(pendingInnerUpdates.length).toBeGreaterThan(0); + + outerGroupEl.selected = true; + // only the resolved leaf is selected so far, making the outer group appear fully selected: + expect(leafColumn.selected).toBe(true); + expect(outerGroup.selected).toBe(true); + + scheduleSpy.mockRestore(); + pendingInnerUpdates.forEach(query => scheduleQueryUpdate(query)); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + expect(innerGroup.children.toArray()).toEqual([innerColumn]); + expect(leafColumn.selected).toBe(true); + expect(innerColumn.selected).toBe(true); + expect(outerGroup.selected).toBe(true); + }); + it('should hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; @@ -784,6 +1017,24 @@ describe('Elements: ', () => { expect(value.nested.column).not.toBe(column); }); + it('should replace components in event args arrays with proxies', () => { + const columns = gridEl.columns as IgxColumnComponent[]; + + const { value, hasProxies } = strategy.createProxyForComponentValue(columns, 1); + + expect(hasProxies).toBe(true); + expect(value.length).toBe(columns.length); + value.forEach((proxy, i) => { + expect(proxy).not.toBe(columns[i]); + expect(proxy.field).toBe(columns[i].field); + }); + + const headerTemplate = () => html`Header`; + value[1].headerTemplate = headerTemplate; + expect(columns[1].headerTemplate).toBeInstanceOf(TemplateRefWrapper); + expect(strategy.templateWrapper.getTemplateFunction(columns[1].headerTemplate)).toBe(headerTemplate); + }); + it('should not proxy values beyond the max depth, events, arrays without components or components without template props', () => { const paginator = gridEl.querySelector('igc-paginator'); const paginatorInstance = (paginator.ngElementStrategy as any).componentRef.instance; @@ -802,13 +1053,32 @@ describe('Elements: ', () => { expect(deep.a.b.c.column).toBe(column); }); - it('should only patch columnInit event args', () => { + it('should only patch columnInit and columnsAutogenerated event args', () => { const args = { column }; expect(strategy.patchOutputComponents('rendered', args)).toBe(args); expect(args.column).toBe(column); expect(strategy.patchOutputComponents('columnInit', column)).not.toBe(column); }); + + it('should proxy autogenerated columns emitted through columnsAutogenerated', async () => { + const autoGridEl = document.createElement('igc-grid') as IgcNgElement & InstanceType; + autoGridEl.autoGenerate = true; + autoGridEl.data = SampleTestData.foodProductData(); + const autoGenerated = firstValueFrom(fromEvent(autoGridEl, 'columnsAutogenerated')); + testContainer.appendChild(autoGridEl); + + const emitted = (await autoGenerated).detail.columns as IgxColumnComponent[]; + const autoGrid = (await autoGridEl.ngElementStrategy[ComponentRefKey]).instance as IgxGridComponent; + const target = autoGrid.columns.find(c => c.field === 'ProductName'); + const proxy = emitted.find(c => c.field === 'ProductName'); + expect(proxy).not.toBe(target); + + const headerTemplate = () => html`Header`; + proxy.headerTemplate = headerTemplate as any; + expect(target.headerTemplate).toBeInstanceOf(TemplateRefWrapper); + expect((autoGridEl.ngElementStrategy as any).templateWrapper.getTemplateFunction(target.headerTemplate)).toBe(headerTemplate); + }); }); describe('Hierarchical grid integration.', () => { diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index dd27277d4e3..e4373ac11da 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -33,6 +33,17 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { private setComponentRef!: (value: ComponentRef) => void; /** The maximum depth at which event arguments are processed and angular components wrapped with Proxies, that handle template set */ private maxEventProxyDepth = 3; + /** + * Additional property values set before content queries resolve, which may depend on + * content children (e.g. column group `selected`). Retried once content queries resolve. + */ + private pendingContentValues = new Map(); + /** Whether the initial content queries of the component have resolved */ + private queriesResolved = false; + /** Whether the initial content queries of the component and of all its query children have resolved */ + private contentResolved = false; + /** Child elements connected under this component that haven't scheduled their content query updates yet */ + private initializingChildren = new Set(); /** * Resolvable component reference. @@ -86,6 +97,9 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { return; } this.element = element as IgcNgElement; + this.queriesResolved = false; + this.contentResolved = false; + this.pendingContentValues.clear(); // set componentRef to non-null to prevent DOM moves from re-initializing // TODO: Fail handling or cancellation needed? @@ -101,6 +115,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { // } let parentInjector!: Injector; let parentAnchor!: ViewContainerRef; + let parentStrategy: IgxCustomNgElementStrategy | undefined; const parents: WeakRef[] = []; const componentConfig = this.config?.find(x => x.component === this._component); @@ -130,6 +145,9 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { // ngElementStrategy getter is protected and also has initialization logic, though that should be safe at this point if (parent?.ngElementStrategy) { + // register synchronously, so the parent doesn't resolve its content before this child is in its queries + parentStrategy = parent.ngElementStrategy; + parentStrategy.addInitializingChild(this); this.angularParent = parent.ngElementStrategy.angularParent; this.parentElement = new WeakRef(parent); const parentComponentRef = await parent?.ngElementStrategy[ComponentRefKey]; @@ -182,6 +200,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { const contentQueryChildrenCollection = componentConfig!.contentQueries.filter(x => contentChildrenTypes.includes(x.childType)); if (contentQueryChildrenCollection.length === 0) { // no content children, emit event immediately, since there's nothing to be attached. + this.queriesResolved = true; + this.resolveContent(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } @@ -225,6 +245,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { parentRef.changeDetectorRef.detectChanges(); } } + parentStrategy?.removeInitializingChild(this); if (['igc-grid', 'igc-tree-grid', 'igc-hierarchical-grid'].includes(element.tagName.toLocaleLowerCase())) { this.patchGridPopups(); @@ -255,10 +276,14 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { (this as any).initialInputValues.set(property, value); return; } + // a newer value always takes precedence over one pending content resolution + this.pendingContentValues.delete(property); const componentRef = (this as any).componentRef as ComponentRef; const componentConfig = this.config?.find(x => x.component === this._component); - if (value === componentRef.instance[property]) { + // writable additional properties may have derived getters (e.g. column group `selected`), + // so a matching value doesn't mean the setter has nothing to update: + if (this._inputMap.has(property) && value === componentRef.instance[property]) { return; } if (componentRef && componentConfig?.templateProps?.includes(property)) { @@ -296,9 +321,91 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { if (componentConfig!.selector === 'igc-pivot-data-selector' && property === 'grid' && value) { value = value.ngElementStrategy?.componentRef?.instance || value; } + if (!this._inputMap.has(property)) { + // writable additional properties aren't inputs and can't be set through `setInput` + componentRef.instance[property] = value; + if (!this.contentResolved) { + // may depend on content children (or their descendants) that aren't resolved yet. + // A matching getter can't confirm the value reached all of them, so retry once they are: + this.pendingContentValues.set(property, value); + } + componentRef.changeDetectorRef.markForCheck(); + this.notifyChanges(); + return; + } super.setInputValue(property, value); } + /** + * Set any stored initial values on the component, applying Angular inputs first. + * Writable additional properties (e.g. column `selected`) may depend on inputs (e.g. `field`), + * so they are replayed after them regardless of the order they were set before connect. + */ + protected override initializeInputs(): void { + const initialInputValues = (this as any).initialInputValues as Map; + const entries = Array.from(initialInputValues); + initialInputValues.clear(); + + for (const [property, value] of entries.filter(([prop]) => this._inputMap.has(prop))) { + this.setInputValue(property, value); + } + for (const [property, value] of entries.filter(([prop]) => !this._inputMap.has(prop))) { + this.setInputValue(property, value); + } + } + + /** + * Mark content as resolved once the component's own queries and those of all its query children have resolved, + * retry pending values that depend on them and notify the parent, which may be waiting on this component. + */ + private resolveContent(): void { + if (this.contentResolved || !this.queriesResolved || this.schedule.size || this.initializingChildren.size || this.hasUnresolvedQueryChildren()) { + return; + } + this.contentResolved = true; + this.applyPendingContentValues(); + this.parentElement?.deref()?.ngElementStrategy?.resolveContent(); + } + + /** + * Track a newly connected child until it schedules its content query updates. + * Content of this component and its ancestors is no longer resolved until then. + */ + private addInitializingChild(child: IgxCustomNgElementStrategy): void { + this.initializingChildren.add(child); + let strategy: IgxCustomNgElementStrategy | undefined = this; + while (strategy) { + strategy.contentResolved = false; + strategy = strategy.parentElement?.deref()?.ngElementStrategy; + } + } + + /** Stop tracking a child that has scheduled its content query updates and retry resolving content */ + private removeInitializingChild(child: IgxCustomNgElementStrategy): void { + this.initializingChildren.delete(child); + this.resolveContent(); + } + + /** Whether any of the component's content query children (e.g. a nested column group) haven't resolved their own content yet */ + private hasUnresolvedQueryChildren(): boolean { + const componentConfig = this.config?.find(x => x.component === this._component); + return !!componentConfig?.contentQueries.some(query => + this.runQueryInDOM(this.element, query).some(child => child.ngElementStrategy?.contentResolved === false) + ); + } + + /** Retry pending additional property values once content queries have resolved */ + private applyPendingContentValues(): void { + if (!this.pendingContentValues.size || !(this as any).componentRef?.instance) { + return; + } + const entries = Array.from(this.pendingContentValues); + this.pendingContentValues.clear(); + for (const [property, value] of entries) { + this.setInputValue(property, value); + } + } + public override getInputValue(property: string): any { let returnValue = super.getInputValue(property); @@ -354,7 +461,9 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { list.notifyOnChanges(); } if (this.schedule.size === 0) { - // children are attached and collections are updated, emit event. + // children are attached and collections are updated, retry values that depend on them and emit event. + this.queriesResolved = true; + this.resolveContent(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } } @@ -477,8 +586,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { } protected patchOutputComponents(eventName: string, eventArgs: any) { - // Single out only `columnInit` event for now. If more events pop up will require a config generation. - if (eventName !== "columnInit") { + // Single out only column events for now. If more events pop up will require a config generation. + if (eventName !== "columnInit" && eventName !== "columnsAutogenerated") { return eventArgs; } return this.createProxyForComponentValue(eventArgs, 1).value; @@ -508,8 +617,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { // For array limit their parsing to first level and check if first item has created proxy inside. const firstItem = this.createProxyForComponentValue(value[0], this.maxEventProxyDepth); if (firstItem.hasProxies) { - const mappedArray = value.slice(1, value.length).map(item => this.createProxyForComponentValue(item, depth + 1)); - mappedArray.unshift(firstItem); + const mappedArray = value.slice(1, value.length).map(item => this.createProxyForComponentValue(item, depth + 1).value); + mappedArray.unshift(firstItem.value); return { value: mappedArray, hasProxies: true }; } } diff --git a/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.spec.ts b/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.spec.ts index fc984090e92..eaa1e65b691 100644 --- a/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.spec.ts +++ b/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.spec.ts @@ -114,6 +114,27 @@ describe('Icon broadcast service', () => { expect(addSvgSpy).not.toHaveBeenCalled(); expect(setRefSpy).not.toHaveBeenCalled(); }); + + it('should only process the part of the message that matches its action type.', async () => { + const iconService = fixture.componentInstance.iconService; + const addSvgSpy = vi.spyOn(iconService, 'addSvgIconFromText'); + const setRefSpy = vi.spyOn(iconService, 'setIconRef'); + const collections = new Map([["customCollection", new Map([["customIcon", { svg: buildIcon }]])]]); + const references = new Map([["customCollection", new Map([["customIcon", { name: "customIcon", collection: "customCollection" }]])]]); + + broadcastChannel.postMessage({ actionType: ActionType.RegisterIcon, collections, references }); + await wait(50); + + expect(addSvgSpy).toHaveBeenCalledExactlyOnceWith("customIcon", buildIcon, "customCollection"); + expect(setRefSpy).not.toHaveBeenCalled(); + + addSvgSpy.mockClear(); + broadcastChannel.postMessage({ actionType: ActionType.UpdateIconReference, collections, references }); + await wait(50); + + expect(addSvgSpy).not.toHaveBeenCalled(); + expect(setRefSpy).toHaveBeenCalledExactlyOnceWith("customIcon", "default", { family: "customCollection", name: "customIcon" }); + }); }); describe('Page lifecycle', () => { diff --git a/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.ts b/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.ts index 27e873d9666..e64e7b30791 100644 --- a/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.ts +++ b/projects/igniteui-angular-elements/src/lib/icon.broadcast.service.ts @@ -42,11 +42,11 @@ export class IgxIconBroadcastService { public handleEvent({ data }: MessageEvent) { const { actionType, collections, references } = data; - if (actionType === ActionType.SyncState || ActionType.RegisterIcon) { + if (actionType === ActionType.SyncState || actionType === ActionType.RegisterIcon) { this.updateIconsFromCollection(collections!); } - if (actionType === ActionType.SyncState || ActionType.UpdateIconReference) { + if (actionType === ActionType.SyncState || actionType === ActionType.UpdateIconReference) { this.updateRefsFromCollection(references!); } } diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts index 2b1be4d8c50..89b7db016df 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts @@ -275,6 +275,17 @@ describe('IgxTreeGrid Component Tests #tGrid', () => { expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); }); + it('should auto-generate columns when data is set before the grid is initialized', async () => { + grid.childDataKey = 'Employees'; + expect(() => grid.data = SampleTestData.employeeAllTypesTreeData()).not.toThrow(); + + fix.detectChanges(); + await fix.whenStable(); + + const expectedColumns = [...Object.keys(grid.data[0])].filter(col => col !== grid.childDataKey); + expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); + }); + it('should recreate columns when data changes and autoGenerate is true', async () => { grid.width = '500px'; grid.height = '500px'; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts index 2d0b50373be..2d0839c89e5 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts @@ -388,7 +388,7 @@ export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridTy if (!this._init) { this.validation.updateAll(this._data); } - if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data)) { + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data) && this.gridAPI.grid) { this.setupColumns(); } this.checkPrimaryKeyField();