From 98060603433038241193a52836a7da2f0c636df3 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:03:04 +0300 Subject: [PATCH 01/22] test(elements, extras): adding more tests for additional coverage --- .../src/app/create-custom-element.spec.ts | 160 +++++++++ .../src/app/custom-strategy-deferred.spec.ts | 48 +++ .../src/app/custom-strategy.spec.ts | 326 +++++++++++++++++- .../src/app/ng-element-strategy.spec.ts | 187 ++++++++++ .../src/app/wrapper/wrapper.component.spec.ts | 87 ++++- .../src/lib/exporters.spec.ts | 63 ++++ .../src/lib/icon.broadcast.service.spec.ts | 50 ++- .../src/lib/state.component.spec.ts | 148 ++++++++ .../src/utils/register.spec.ts | 82 +++++ .../chart-dialog.component.spec.ts | 111 +++++- .../context-menu.component.spec.ts | 324 ++++++++++++++++- .../igx-context-menu.directive.spec.ts | 116 +++++++ .../chart-integration.directive.spec.ts | 117 ++++++- .../chart-integration/initializers.spec.ts | 91 ++++- .../conditional-formatting.directive.spec.ts | 74 ++++ 15 files changed, 1972 insertions(+), 12 deletions(-) create mode 100644 projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/exporters.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/state.component.spec.ts create mode 100644 projects/igniteui-angular-elements/src/utils/register.spec.ts 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 new file mode 100644 index 00000000000..1cbc3feab70 --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts @@ -0,0 +1,160 @@ +import { Component, Input } from '@angular/core'; +import { + FilteringExpressionsTree, + FilteringLogic, + IgxBooleanFilteringOperand, + IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxGridComponent, + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxTimeFilteringOperand +} from 'igniteui-angular'; +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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-custom-element-test', + template: `{{ value }}`, + standalone: true +}) +class CustomElementTestComponent { + @Input() + public value: string; + + @Input() + public showUI: boolean; + + public counter = 0; + public readonlyProp = 'readonly'; + public writableProp = 'writable'; + + public increment(by: number) { + this.counter += by; + return this.counter; + } +} + +const TEST_TAG = 'igc-custom-element-test'; + +const createConfig = (overrides: Partial = {}): ComponentConfig[] => [{ + component: CustomElementTestComponent, + selector: TEST_TAG, + parents: [], + contentQueries: [], + additionalProperties: [ + { name: 'readonlyProp' }, + { name: 'writableProp', writable: true } + ], + methods: ['increment'], + ...overrides +}]; + +describe('createIgxCustomElement', () => { + const elementCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + if (!customElements.get(TEST_TAG)) { + customElements.define(TEST_TAG, elementCtor); + } + + let element: IgcNgElement & CustomElementTestComponent; + + beforeEach(() => { + element = document.createElement(TEST_TAG) as IgcNgElement & CustomElementTestComponent; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + vi.restoreAllMocks(); + }); + + const getInstance = async () => + (await element.ngElementStrategy[ComponentRefKey]).instance as CustomElementTestComponent; + + it('should expose the configured selector as static tagName', () => { + expect(elementCtor.tagName).toBe(TEST_TAG); + expect(Object.getOwnPropertyDescriptor(elementCtor, 'tagName').writable).toBe(false); + }); + + it('should proxy configured methods to the component instance and schedule change detection', async () => { + const instance = await getInstance(); + const notifySpy = vi.spyOn(element.ngElementStrategy, 'notifyChanges'); + + expect(element.increment(2)).toBe(2); + expect(element.increment(3)).toBe(5); + + expect(instance.counter).toBe(5); + expect(notifySpy).toHaveBeenCalledTimes(2); + }); + + it('should expose additional properties as getters on the element', async () => { + const instance = await getInstance(); + + expect(element.readonlyProp).toBe('readonly'); + instance.readonlyProp = 'changed'; + expect(element.readonlyProp).toBe('changed'); + }); + + it('should only allow setting writable additional properties', async () => { + const instance = await getInstance(); + const setInputSpy = vi.spyOn(element.ngElementStrategy, 'setInputValue').mockImplementation(() => {}); + + element.writableProp = 'updated'; + expect(setInputSpy).toHaveBeenCalledExactlyOnceWith('writableProp', 'updated'); + + expect(() => (element as any).readonlyProp = 'nope').toThrow(TypeError); + expect(instance.readonlyProp).toBe('readonly'); + }); + + it('should throw for additional properties that clash with the element prototype', () => { + const config = createConfig({ additionalProperties: [{ name: 'connectedCallback' }] }); + + expect(() => createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: config })) + .toThrowError(`Potentially illegal property name connectedCallback defined for ${CustomElementTestComponent.name}`); + }); + + it('should add the deprecated getFilterFactory to IgxGridComponent elements', () => { + const config: ComponentConfig[] = [{ + component: IgxGridComponent, + selector: 'igc-filter-factory-test-grid', + parents: [], + contentQueries: [], + additionalProperties: [], + methods: [] + }]; + const gridCtor = createIgxCustomElement(IgxGridComponent, { injector, registerConfig: config }); + const otherCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + + expect(otherCtor.prototype.getFilterFactory).toBeUndefined(); + + const factory = gridCtor.prototype.getFilterFactory(); + expect(factory.stringFilteringOperand).toBe(IgxStringFilteringOperand.instance()); + expect(factory.numberFilteringOperand).toBe(IgxNumberFilteringOperand.instance()); + expect(factory.timeFilteringOperand).toBe(IgxTimeFilteringOperand.instance()); + expect(factory.dateTimeFilteringOperand).toBe(IgxDateTimeFilteringOperand.instance()); + expect(factory.dateFilteringOperand).toBe(IgxDateFilteringOperand.instance()); + expect(factory.booleanFilteringOperand).toBe(IgxBooleanFilteringOperand.instance()); + + const tree = factory.createFilteringExpressionTree(FilteringLogic.Or, 'Name'); + expect(tree).toBeInstanceOf(FilteringExpressionsTree); + expect(tree.operator).toBe(FilteringLogic.Or); + expect(tree.fieldName).toBe('Name'); + }); +}); + +describe('withRegister', () => { + it('should attach a non-writable static register function', () => { + const ctor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + const register = vi.fn(); + + const component = withRegister(ctor, register); + component.register(); + + expect(component).toBe(ctor); + expect(register).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(component, 'register').writable).toBe(false); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts new file mode 100644 index 00000000000..a008cb8ff8a --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -0,0 +1,48 @@ +import { firstValueFrom, fromEvent } from 'rxjs'; +import { ComponentRefKey, IgcNgElement } from './custom-strategy'; +import { IgcColumnComponent, IgcTreeGridComponent } from './components'; +import { defineComponents } from '../utils/register'; +import { IgxTreeGridComponent } from '../lib/grids/tree-grid.component'; +import { describe, it, expect } from 'vitest'; + +describe('Elements: deferred parent definition', () => { + it('should wait for the parent element to be defined before attaching to it', async () => { + // Relies on the per-file isolation of the test runner for a clean custom elements registry + expect(customElements.get('igc-tree-grid')).toBeUndefined(); + defineComponents(IgcColumnComponent); + + const container = document.createElement('div'); + document.body.appendChild(container); + try { + container.innerHTML = ` + + + + `; + const treeGridEl = container.querySelector('#treeGrid'); + const columnEls = Array.from(container.querySelectorAll('igc-column')); + + // columns connect first, but have to wait for their parent to be defined + let columnsResolved = false; + const columnRefs = Promise.all(columnEls.map(c => c.ngElementStrategy[ComponentRefKey])) + .then(refs => { + columnsResolved = true; + return refs; + }); + await Promise.resolve(); + expect(columnsResolved).toBe(false); + + const childrenResolved = firstValueFrom(fromEvent(treeGridEl, 'childrenResolved')); + defineComponents(IgcTreeGridComponent); + + const treeGrid = (await (treeGridEl as IgcNgElement).ngElementStrategy[ComponentRefKey]).instance as IgxTreeGridComponent; + const columns = (await columnRefs).map(ref => ref.instance); + await childrenResolved; + + expect(treeGrid.columnList.toArray()).toEqual(columns); + expect(treeGrid.columns.map(c => c.field)).toEqual(['ID', 'Name']); + } finally { + container.remove(); + } + }); +}); 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 8e1a286b23b..add696e8bc6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,5 +1,5 @@ import { ApplicationRef, ViewContainerRef } from '@angular/core'; -import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType } from 'igniteui-angular'; +import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; @@ -23,9 +23,11 @@ import { IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, IgcGridToolbarHidingComponent, + IgcTreeGridComponent, } from './components'; +import { TemplateRefWrapper } from './wrapper/template-ref-wrapper'; import { defineComponents } from '../utils/register'; -import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; +import { describe, beforeAll, beforeEach, afterEach, it, expect, vi } from 'vitest'; describe('Elements: ', () => { let testContainer: HTMLDivElement; @@ -47,7 +49,8 @@ describe('Elements: ', () => { IgcGridToolbarActionsComponent, IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, - IgcGridToolbarHidingComponent + IgcGridToolbarHidingComponent, + IgcTreeGridComponent ); }); @@ -581,5 +584,322 @@ describe('Elements: ', () => { highlightedCell = gridEl.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); expect(highlightedCell).toBeNull(); }); + + it('should emit columnsAutogenerated and selectedRowsChange for tree grid', async () => { + const treeGridEl = document.createElement('igc-tree-grid') as IgcNgElement & InstanceType; + treeGridEl.autoGenerate = true; + treeGridEl.primaryKey = 'ID'; + treeGridEl.childDataKey = 'Employees'; + + 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']); + + const selectionChanged = firstValueFrom(fromEvent(treeGridEl, 'selectedRowsChange')); + treeGridEl.selectRows([147]); + expect((await selectionChanged).detail).toEqual([147]); + }); + + it('should emit columnsAutogenerated and selectedRowsChange for grid', async () => { + const gridEl = document.createElement('igc-grid'); + gridEl.autoGenerate = true; + gridEl.primaryKey = 'ProductID'; + gridEl.data = SampleTestData.foodProductData(); + const autoGenerated = firstValueFrom(fromEvent(gridEl, 'columnsAutogenerated')); + + testContainer.appendChild(gridEl); + + const columns = (await autoGenerated).detail.columns as IgxColumnComponent[]; + expect(columns.map(c => c.field)).toContain('ProductName'); + + const selectionChanged = firstValueFrom(fromEvent(gridEl, 'selectedRowsChange')); + gridEl.selectRows([1, 2]); + expect((await selectionChanged).detail).toEqual([1, 2]); + }); + }); + + describe('Element property handling.', () => { + let gridEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + + beforeEach(async () => { + gridEl = document.createElement('igc-grid') as IgcNgElement & InstanceType; + testContainer.appendChild(gridEl); + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + }); + + it('should wrap template functions in template refs and return the original function', async () => { + const emptyTemplate = () => html`Nothing here`; + + gridEl.emptyGridTemplate = emptyTemplate as any; + + const templateRef = grid.emptyGridTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(gridEl.emptyGridTemplate).toBe(emptyTemplate); + + // setting the same function again should keep the same template ref + gridEl.emptyGridTemplate = emptyTemplate as any; + expect(grid.emptyGridTemplate).toBe(templateRef); + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(gridEl.querySelector('.test-empty')?.textContent).toBe('Nothing here'); + + const otherTemplate = () => html`Still nothing`; + gridEl.emptyGridTemplate = otherTemplate as any; + expect(grid.emptyGridTemplate).not.toBe(templateRef); + expect(gridEl.emptyGridTemplate).toBe(otherTemplate); + }); + + it('should tie Elements rendered in templates to the component lifecycle', async () => { + gridEl.emptyGridTemplate = (() => html``) as any; + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + + const templatePaginator = gridEl.querySelector('#templatePaginator'); + expect(templatePaginator).toBeTruthy(); + expect((templatePaginator.ngElementStrategy as any).angularParent.instance).toBe(grid); + }); + + it('should coerce bool and numeric props', 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; + + (columnEl as any).hidden = 'true'; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = 'false'; + expect(column.hidden).toBe(false); + (columnEl as any).hidden = ''; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = null; + expect(column.hidden).toBe(false); + + (columnEl as any).rowStart = '2'; + expect(column.rowStart).toBe(2); + (columnEl as any).rowStart = 'not a number'; + expect(column.rowStart).toBe('not a number'); + }); + + it('should hide opened toggle components in the grid on grid scroll', () => { + const createToggle = () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + return toggle; + }; + const toggle = createToggle(); + const nonToggle = createToggle(); + const detachedToggle = createToggle(); + + const scroll = () => gridEl.dispatchEvent(new CustomEvent('gridScroll')); + const open = (el: HTMLElement, detail = {}) => el.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail })); + + // nothing opened, nothing to hide + scroll(); + + open(toggle); + open(nonToggle, { owner: 'combo' }); + open(detachedToggle); + detachedToggle.remove(); + scroll(); + + expect(toggle.hide).toHaveBeenCalledTimes(1); + expect(nonToggle.hide).not.toHaveBeenCalled(); + expect(detachedToggle.hide).not.toHaveBeenCalled(); + + // opened toggles are cleared after hiding + scroll(); + expect(toggle.hide).toHaveBeenCalledTimes(1); + }); + + it('should stop handling grid popups once the grid is destroyed', async () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + toggle.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail: {} })); + + gridEl.remove(); + await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + gridEl.dispatchEvent(new CustomEvent('gridScroll')); + + expect(toggle.hide).not.toHaveBeenCalled(); + }); + }); + + describe('Event args component proxies.', () => { + let gridEl: IgcNgElement & InstanceType; + let strategy: any; + let column: IgxColumnComponent; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + strategy = gridEl.ngElementStrategy; + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + column = gridEl.columns[0] as IgxColumnComponent; + }); + + it('should wrap components with template props in a proxy that converts templates', () => { + const { value: proxy, hasProxies } = strategy.createProxyForComponentValue(column, 1); + expect(hasProxies).toBe(true); + expect(proxy).not.toBe(column); + + const headerTemplate = () => html`Header`; + proxy.headerTemplate = headerTemplate; + const templateRef = column.headerTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(strategy.templateWrapper.getTemplateFunction(templateRef)).toBe(headerTemplate); + + // same template function keeps the existing ref + proxy.headerTemplate = headerTemplate; + expect(column.headerTemplate).toBe(templateRef); + + // non-template props are set as is + proxy.header = 'Custom header'; + expect(column.header).toBe('Custom header'); + }); + + it('should replace components nested in event args objects with proxies', () => { + const args = { column, owner: null, nested: { column } }; + + const { value, hasProxies } = strategy.createProxyForComponentValue(args, 1); + + expect(hasProxies).toBe(true); + expect(value).toBe(args); + expect(value.owner).toBeNull(); + expect(value.column).not.toBe(column); + expect(value.column.field).toBe(column.field); + expect(value.nested.column).not.toBe(column); + }); + + 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; + const event = new Event('test'); + const deep = { a: { b: { c: { column } } } }; + const plainArray = [{ field: 'ProductID' }]; + + expect(strategy.createProxyForComponentValue(column, 4)).toEqual({ value: column, hasProxies: false }); + expect(strategy.createProxyForComponentValue(event, 1)).toEqual({ value: event, hasProxies: false }); + expect(strategy.createProxyForComponentValue([], 1)).toEqual({ value: [], hasProxies: false }); + expect(strategy.createProxyForComponentValue(plainArray, 1)).toEqual({ value: plainArray, hasProxies: false }); + expect(strategy.createProxyForComponentValue(paginatorInstance, 1)).toEqual({ value: paginatorInstance, hasProxies: false }); + + const { hasProxies } = strategy.createProxyForComponentValue(deep, 1); + expect(hasProxies).toBe(false); + expect(deep.a.b.c.column).toBe(column); + }); + + it('should only patch columnInit 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); + }); + }); + + describe('Hierarchical grid integration.', () => { + let hgridEl: IgcNgElement & InstanceType; + let rowIslandEl: IgcNgElement & InstanceType; + let hgrid: IgxHierarchicalGridComponent; + + const waitFor = async (condition: () => boolean) => { + for (let waited = 0; waited < 3000 && !condition(); waited += 20) { + await firstValueFrom(timer(20)); + } + }; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + + `; + hgridEl = testContainer.querySelector('#testHGrid'); + rowIslandEl = testContainer.querySelector('#developers'); + hgrid = (await hgridEl.ngElementStrategy[ComponentRefKey]).instance; + await rowIslandEl.ngElementStrategy[ComponentRefKey]; + hgrid.data = hgridData; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + const expandFirstRow = async () => { + hgrid.expandRow(hgridData[0].ProjectId); + await waitFor(() => hgrid.gridAPI.getChildGrids(false).length > 0); + return hgrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + }; + + it('should re-emit child grid events on the row island with the child grid as owner', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.columns.map(c => c.field)).toContain('DeveloperId'); + + // events without args are re-emitted with the owner only + const rangeSelected = firstValueFrom(fromEvent(rowIslandEl, 'rangeSelected')); + childGrid.rangeSelected.emit(); + expect((await rangeSelected).detail).toEqual({ owner: childGrid }); + + const sortingDone = firstValueFrom(fromEvent(rowIslandEl, 'sortingDone')); + childGrid.sort({ fieldName: 'DeveloperId', dir: SortingDirection.Desc, ignoreCase: false }); + const sortingArgs = (await sortingDone).detail; + expect(sortingArgs.owner).toBe(childGrid); + }); + + it('should emit selectedRowsChange for hierarchical grid', async () => { + const selectionChanged = firstValueFrom(fromEvent(hgridEl, 'selectedRowsChange')); + hgridEl.selectRows([hgridData[0].ProjectId]); + + expect((await selectionChanged).detail).toEqual([hgridData[0].ProjectId]); + }); + + it('should apply row island input changes to existing child grids', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.allowFiltering).toBe(false); + + rowIslandEl.allowFiltering = true; + await waitFor(() => childGrid.allowFiltering); + + expect(childGrid.allowFiltering).toBe(true); + }); + + it('should render the row island paginator template in child grids and keep it in the root paginator query', async () => { + rowIslandEl.paginatorTemplate = (() => html``) as any; + const childGrid = await expandFirstRow(); + + await waitFor(() => !!childGrid.paginator && hgrid.paginatorList?.length > 0); + + const paginatorEl = childGrid.nativeElement.querySelector('igc-paginator'); + expect(paginatorEl).toBeTruthy(); + const paginator = (await paginatorEl.ngElementStrategy[ComponentRefKey]).instance; + expect(childGrid.paginator).toBe(paginator); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((hgridEl.ngElementStrategy as any).cachedChildComponents.get('paginatorList')).toContain(paginator); + await waitFor(() => childGrid.dataView.length === 2); + expect(childGrid.dataView.length).toBe(2); + + // collapsing detaches the cached child grid from the DOM, the paginator stays registered and alive + hgrid.collapseRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(paginatorEl.isConnected).toBe(false); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((paginatorEl.ngElementStrategy as any).componentRef).toBeTruthy(); + + hgrid.expandRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(childGrid.paginator).toBe(paginator); + }); }); }); diff --git a/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts new file mode 100644 index 00000000000..a93a8c48a0b --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts @@ -0,0 +1,187 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgElementStrategyEvent } from '@angular/elements'; +import { firstValueFrom, timer } from 'rxjs'; +import { ComponentNgElementStrategy, ComponentNgElementStrategyFactory, extractProjectableNodes } from './ng-element-strategy'; +import { injector } from '../utils/injector-ref'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-strategy-test', + template: `
{{ value }}
`, + standalone: true +}) +class StrategyTestComponent { + @Input() + public value: string; + + @Input('aliasName') + public aliased: string; + + @Output() + public valueChanged = new EventEmitter(); + + public destroyed = false; + + public ngOnDestroy() { + this.destroyed = true; + } +} + +/** DESTROY_DELAY in ng-element-strategy */ +const DESTROY_DELAY = 10; + +describe('ComponentNgElementStrategy', () => { + let host: HTMLElement; + let strategy: ComponentNgElementStrategy; + + const componentRef = () => (strategy as any).componentRef; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + strategy = new ComponentNgElementStrategyFactory(StrategyTestComponent).create(injector); + }); + + afterEach(() => { + strategy.disconnect(); + host.remove(); + }); + + it('should create strategies that map input property names to template names', () => { + const factory = new ComponentNgElementStrategyFactory(StrategyTestComponent); + + expect(factory.create(injector)).toBeInstanceOf(ComponentNgElementStrategy); + expect(factory.inputMap.get('value')).toBe('value'); + expect(factory.inputMap.get('aliased')).toBe('aliasName'); + }); + + it('should cache input values set before connect and apply them on connect', () => { + strategy.setInputValue('value', 'initial'); + strategy.setInputValue('aliased', 'aliased initial'); + + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBe('initial'); + + strategy.connect(host); + + const instance = componentRef().instance as StrategyTestComponent; + expect(instance.value).toBe('initial'); + expect(instance.aliased).toBe('aliased initial'); + expect(host.querySelector('.value').textContent).toBe('initial'); + expect((strategy as any).initialInputValues.size).toBe(0); + }); + + it('should set inputs on the component through their template name after connect', () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + expect(strategy.getInputValue('aliased')).toBe('new value'); + + // setting an unchanged value should leave the view clean + componentRef().hostView.detectChanges(); + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + }); + + it('should emit component outputs through the events stream', () => { + const events: NgElementStrategyEvent[] = []; + const sub = strategy.events.subscribe(e => events.push(e)); + strategy.connect(host); + + (componentRef().instance as StrategyTestComponent).valueChanged.emit('changed'); + expect(events).toEqual([{ name: 'valueChanged', value: 'changed' }]); + + sub.unsubscribe(); + (componentRef().instance as StrategyTestComponent).valueChanged.emit('ignored'); + expect(events.length).toBe(1); + }); + + it('should project child nodes into the matching content slots', () => { + const header = document.createElement('header'); + header.textContent = 'Header'; + const span = document.createElement('span'); + span.textContent = 'Content'; + host.appendChild(span); + host.appendChild(header); + host.appendChild(document.createTextNode('Text')); + + strategy.connect(host); + + expect(host.querySelector('.header').contains(header)).toBe(true); + expect(host.querySelector('.rest').contains(span)).toBe(true); + expect(host.querySelector('.rest').textContent).toBe('ContentText'); + }); + + it('should destroy the component with a delay after disconnect', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + // second disconnect while already scheduled is a no-op + strategy.disconnect(); + expect(instance.destroyed).toBe(false); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(true); + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBeUndefined(); + }); + + it('should keep the component when reconnected before the scheduled destroy', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + strategy.connect(host); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(false); + expect(componentRef().instance).toBe(instance); + }); + + it('should not schedule destroy when disconnected before connect', async () => { + strategy.disconnect(); + expect((strategy as any).scheduledDestroyFn).toBeNull(); + }); +}); + +describe('extractProjectableNodes', () => { + const createHost = (...children: Node[]) => { + const host = document.createElement('div'); + children.forEach(c => host.appendChild(c)); + return host; + }; + + it('should distribute nodes by selector and default the rest to the wildcard slot', () => { + const header = document.createElement('header'); + const footer = document.createElement('div'); + footer.className = 'footer'; + const other = document.createElement('span'); + const text = document.createTextNode('text'); + const host = createHost(header, other, footer, text); + + const nodes = extractProjectableNodes(host, ['header', '*', '.footer']); + + expect(nodes).toEqual([[header], [other, text], [footer]]); + }); + + it('should drop nodes that match no selector when there is no wildcard slot', () => { + const header = document.createElement('header'); + const other = document.createElement('span'); + const host = createHost(other, document.createTextNode('text'), header); + + const nodes = extractProjectableNodes(host, ['header']); + + expect(nodes).toEqual([[header]]); + }); + + it('should return no slots for components without content projection', () => { + const host = createHost(document.createElement('span')); + + expect(extractProjectableNodes(host, [])).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts index 495a61669d7..5cd4a442289 100644 --- a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts +++ b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { html } from 'lit'; import { AsyncDirective, directive } from 'lit/async-directive.js'; import { TemplateWrapperComponent } from './wrapper.component'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { TemplateRefWrapper } from './template-ref-wrapper'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; const directiveLog = []; class ToLowerAsyncDirective extends AsyncDirective { @@ -64,4 +65,88 @@ describe('WrapperComponent', () => { embeddedView.destroy(); expect(directiveLog).toEqual(['render', 'disconnected']); }); + + it('should expose the context through a proxy with implicit and context props', () => { + let templateContext: any; + const context = { $implicit: 'implicit value', text: 'text value' }; + const templateRef = component.addTemplate((ctx) => { + templateContext = ctx; + return html`${ctx.implicit}`; + }); + const embeddedView = templateRef.createEmbeddedView(context); + embeddedView.detectChanges(); + + expect(templateContext.implicit).toBe('implicit value'); + expect(templateContext.text).toBe('text value'); + expect(templateContext.context).toBe(templateContext); + expect('implicit' in templateContext).toBe(true); + expect('text' in templateContext).toBe(false); + // not bridged, so the bridge props are not handled: + expect(templateContext.___contentId).toBeUndefined(); + expect(templateContext.___immediate).toBeUndefined(); + expect(embeddedView.rootNodes[0].textContent).toBe('implicit value'); + }); + + it('should init and teardown bridged templates with their content context', () => { + let templateContext: any; + const templateFunc: any = (ctx) => { + templateContext = ctx; + return html`Bridged`; + }; + templateFunc.___isBridged = true; + templateFunc.___onTemplateInit = vi.fn(); + templateFunc.___onTemplateTeardown = vi.fn(); + + const templateRef = component.addTemplate(templateFunc); + const embeddedView = templateRef.createEmbeddedView({}); + const root = embeddedView.rootNodes[0]; + + expect(templateFunc.___onTemplateInit).toHaveBeenCalledTimes(1); + const [initFunc, initRoot, contentContext] = templateFunc.___onTemplateInit.mock.calls[0]; + expect(initFunc).toBe(templateFunc); + expect(initRoot).toBe(root); + expect(contentContext.root).toBe(root); + expect(contentContext.templateFunction).toBe(templateFunc); + expect(contentContext._id).toBeTruthy(); + expect(root._id).toBe(contentContext._id); + expect((templateRef as any)._contentContext.get(contentContext._id)).toBe(contentContext); + + embeddedView.detectChanges(); + expect(templateContext.___contentId).toBe(contentContext._id); + expect(templateContext.___immediate).toBe(true); + expect(templateContext.___root).toBe(root); + + embeddedView.destroy(); + expect(templateFunc.___onTemplateTeardown).toHaveBeenCalledExactlyOnceWith(templateFunc, root, contentContext); + expect((templateRef as any)._contentContext.size).toBe(0); + }); + + it('should expose the inner template elementRef and ssrId', () => { + const templateRef = component.addTemplate(() => html``) as TemplateRefWrapper; + + expect(templateRef.elementRef).toBe(templateRef.innerTemplateRef.elementRef); + expect(templateRef.ssrId).toBe((templateRef.innerTemplateRef as any).ssrId); + }); + + it('should resolve the template function from a wrapped template ref', () => { + const first = () => html`1`; + const second = () => html`2`; + const firstRef = component.addTemplate(first) as TemplateRefWrapper; + const secondRef = component.addTemplate(second) as TemplateRefWrapper; + + expect(component.getTemplateFunction(firstRef)).toBe(first); + expect(component.getTemplateFunction(secondRef)).toBe(second); + expect(component.getTemplateFunction(null)).toBeUndefined(); + }); + + it('should emit templateRendered with the template container', () => { + const rendered: HTMLElement[] = []; + component.templateRendered.subscribe(container => rendered.push(container)); + const templateRef = component.addTemplate(() => html`Rendered`); + const embeddedView = templateRef.createEmbeddedView({}); + embeddedView.detectChanges(); + + expect(rendered).toEqual([embeddedView.rootNodes[0]]); + expect(rendered[0].textContent).toBe('Rendered'); + }); }); diff --git a/projects/igniteui-angular-elements/src/lib/exporters.spec.ts b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts new file mode 100644 index 00000000000..3be52ad3579 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts @@ -0,0 +1,63 @@ +import { IgxCsvExporterOptions, IgxCsvExporterService, IgxExcelExporterOptions, IgxExcelExporterService, CsvFileTypes } from 'igniteui-angular'; +import { IgcCsvExporterService } from './csv-exporter'; +import { IgcExcelExporterService } from './excel-exporter'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('Elements exporter services', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const gridInstance = { id: 'grid-instance' }; + const gridElement = { ngElementStrategy: { componentRef: { instance: gridInstance } } }; + + describe('IgcCsvExporterService', () => { + const options = new IgxCsvExporterOptions('Test', CsvFileTypes.CSV); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcCsvExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcCsvExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); + + describe('IgcExcelExporterService', () => { + const options = new IgxExcelExporterOptions('Test'); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcExcelExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcExcelExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); +}); 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 ac9573567d2..fc984090e92 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 @@ -3,7 +3,7 @@ import { ActionType, BroadcastIconsChangeMessage, IgxIconBroadcastService, SvgIc import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { IconMeta, IgxIconService } from 'igniteui-angular'; import { wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('Icon broadcast service', () => { let fixture: ComponentFixture; @@ -102,7 +102,53 @@ describe('Icon broadcast service', () => { expect(serviceRef.family).toBe("customCollection"); expect(serviceRef.name).toBe("customIcon"); }); - }) + + it('should ignore messages without collections or references.', async () => { + const iconService = fixture.componentInstance.iconService; + const addSvgSpy = vi.spyOn(iconService, 'addSvgIconFromText'); + const setRefSpy = vi.spyOn(iconService, 'setIconRef'); + + broadcastChannel.postMessage({ actionType: ActionType.SyncState }); + await wait(50); + + expect(addSvgSpy).not.toHaveBeenCalled(); + expect(setRefSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Page lifecycle', () => { + it('should close the channel on pagehide and re-create it on pageshow.', async () => { + const iconService = fixture.componentInstance.iconService; + await wait(50); + expect(events.length).toBe(1); + + globalThis.dispatchEvent(new Event('pagehide')); + const icon: Map = new Map([["hiddenIcon", { svg: buildIcon }]]); + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["hiddenCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "hiddenCollection")).toBeUndefined(); + + // hiding again with an already closed channel is a no-op + expect(() => globalThis.dispatchEvent(new Event('pagehide'))).not.toThrow(); + + globalThis.dispatchEvent(new Event('pageshow')); + await wait(50); + // re-created channel requests state sync again + // (services from previous tests listen to the global page events as well) + expect(events.length).toBeGreaterThan(1); + expect(events.every(e => e.actionType === ActionType.SyncState)).toBe(true); + + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["shownCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "shownCollection")).not.toBeUndefined(); + }); + }); }); @Component({ diff --git a/projects/igniteui-angular-elements/src/lib/state.component.spec.ts b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts new file mode 100644 index 00000000000..c1adcc7cfb9 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts @@ -0,0 +1,148 @@ +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; +import { firstValueFrom, timer } from 'rxjs'; +import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; +import { ComponentRefKey, IgcNgElement } from '../app/custom-strategy'; +import { IgcColumnComponent, IgcGridComponent, IgcGridStateComponent } from '../app/components'; +import { defineComponents } from '../utils/register'; +import { IgxGridComponent } from './grids/grid.component'; +import { IGridStateInfo, IgxGridStateComponent } from './state.component'; +import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; + +describe('Elements: Grid state component', () => { + let testContainer: HTMLDivElement; + let gridEl: IgcNgElement & InstanceType; + let stateEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + let state: IgxGridStateComponent; + + const sortingState = () => [{ fieldName: 'ProductID', dir: SortingDirection.Desc, ignoreCase: false }]; + + beforeAll(() => { + defineComponents(IgcGridComponent, IgcColumnComponent, IgcGridStateComponent); + }); + + beforeEach(async () => { + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + stateEl = testContainer.querySelector('igc-grid-state') as any; + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + state = (await stateEl.ngElementStrategy[ComponentRefKey]).instance; + gridEl.data = SampleTestData.foodProductData(); + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + afterEach(() => { + document.body.removeChild(testContainer); + }); + + it('should be bound to its parent grid', () => { + expect(state.grid).toBe(grid); + }); + + it('should get the state of all features by default', () => { + grid.sort(sortingState()); + + const gridState = stateEl.getState(); + + expect(gridState.sorting).toMatchObject(sortingState()); + expect(gridState.columns.map(c => c.field)).toEqual(['ProductID', 'ProductName']); + expect(gridState.filtering).toBeDefined(); + expect(gridState.rowSelection).toBeDefined(); + }); + + it('should get the state of only the requested features', () => { + grid.sort(sortingState()); + + expect(Object.keys(stateEl.getState('sorting'))).toEqual(['sorting']); + expect(Object.keys(stateEl.getState(['sorting', 'columns'])).sort()).toEqual(['columns', 'sorting']); + + const serialized = stateEl.getStateAsString(['sorting']); + expect(JSON.parse(serialized)).toEqual({ sorting: sortingState() }); + expect(Object.keys(JSON.parse(stateEl.getStateAsString())).length).toBeGreaterThan(1); + }); + + it('should apply state of all or only of the requested features', () => { + const gridState: IGridStateInfo = { + sorting: sortingState(), + moving: false + }; + + stateEl.applyState(gridState, ['moving']); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(false); + + stateEl.applyState(gridState); + expect(grid.sortingExpressions).toMatchObject(sortingState()); + expect(grid.dataView[0].ProductID).toBe(Math.max(...gridEl.data.map(x => x.ProductID))); + }); + + it('should emit stateParsed when applying state from string and apply modifications to the parsed state', async () => { + const parsedStates: IGridStateInfo[] = []; + stateEl.addEventListener('stateParsed', (e: CustomEvent) => { + parsedStates.push(structuredClone(e.detail)); + e.detail.sorting[0].dir = SortingDirection.Asc; + }); + + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState() })); + + expect(parsedStates).toEqual([{ sorting: sortingState() }]); + expect(grid.sortingExpressions[0].dir).toBe(SortingDirection.Asc); + + grid.clearSort(); + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState(), moving: true }), 'moving'); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(true); + }); + + it('should serialize Set search values of filtering expressions as arrays', () => { + const condition = IgxStringFilteringOperand.instance().condition('in'); + grid.filter('ProductName', new Set(['Chai', 'Chang']), condition); + + const serialized = JSON.parse(stateEl.getStateAsString(['filtering'])); + const expression = serialized.filtering.filteringOperands[0].filteringOperands[0]; + + expect(expression.searchVal).toEqual(['Chai', 'Chang']); + }); + + it('should omit externalObject values when serializing', () => { + const callback = (key: string, value: any) => (state as any).stringifyCallback(key, value); + const set = new Set([1, 2]); + + expect(callback('externalObject', { some: 'value' })).toBeUndefined(); + expect(callback('searchVal', set)).toEqual([1, 2]); + expect(callback('searchVal', 'text')).toBe('text'); + expect(callback('other', set)).toBe(set); + }); + + it('should apply the state through the element API from a grid in another container', async () => { + const otherContainer = document.createElement('div'); + document.body.appendChild(otherContainer); + try { + otherContainer.innerHTML = ` + + + + `; + const otherGridEl = otherContainer.querySelector('igc-grid'); + const otherStateEl = otherContainer.querySelector>('igc-grid-state'); + const otherGrid = (await otherGridEl.ngElementStrategy[ComponentRefKey]).instance as IgxGridComponent; + await otherStateEl.ngElementStrategy[ComponentRefKey]; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + grid.sort(sortingState()); + otherStateEl.applyStateFromString(stateEl.getStateAsString(['sorting'])); + + expect(otherGrid.sortingExpressions).toMatchObject(sortingState()); + } finally { + otherContainer.remove(); + } + }); +}); diff --git a/projects/igniteui-angular-elements/src/utils/register.spec.ts b/projects/igniteui-angular-elements/src/utils/register.spec.ts new file mode 100644 index 00000000000..8c0c62f3c56 --- /dev/null +++ b/projects/igniteui-angular-elements/src/utils/register.spec.ts @@ -0,0 +1,82 @@ +import { defineComponents, IgniteComponent, registerComponent } from './register'; +import { defineAllComponents } from './define-all'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +let uniqueId = 0; +const createComponent = (): IgniteComponent => { + const ctor = class extends HTMLElement { } as unknown as IgniteComponent; + ctor.tagName = `igc-register-test-${uniqueId++}`; + ctor.register = vi.fn(() => registerComponent(ctor)); + return ctor; +}; + +describe('Elements register utils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerComponent should define the component and register its dependencies first', () => { + const dependency = createComponent(); + const component = createComponent(); + const defineSpy = vi.spyOn(customElements, 'define'); + + registerComponent(component, dependency); + + expect(dependency.register).toHaveBeenCalledTimes(1); + expect(customElements.get(dependency.tagName)).toBe(dependency); + expect(customElements.get(component.tagName)).toBe(component); + expect(defineSpy.mock.calls.map(([tag]) => tag)).toEqual([dependency.tagName, component.tagName]); + }); + + it('registerComponent should not re-define an already defined component', () => { + const component = createComponent(); + registerComponent(component); + const defineSpy = vi.spyOn(customElements, 'define'); + + expect(() => registerComponent(component)).not.toThrow(); + expect(defineSpy).not.toHaveBeenCalled(); + }); + + it('defineComponents should call register on every component', () => { + const components = [createComponent(), createComponent()]; + + defineComponents(...components); + + components.forEach(c => { + expect(c.register).toHaveBeenCalledTimes(1); + expect(customElements.get(c.tagName)).toBe(c); + }); + }); + + it('defineAllComponents should define all grid related elements', () => { + defineAllComponents(); + + const tags = [ + 'igc-grid', + 'igc-tree-grid', + 'igc-hierarchical-grid', + 'igc-pivot-grid', + 'igc-pivot-data-selector', + 'igc-row-island', + 'igc-column-group', + 'igc-column-layout', + 'igc-column', + 'igc-paginator', + 'igc-grid-toolbar', + 'igc-action-strip', + 'igc-grid-state', + 'igc-grid-editing-actions', + 'igc-grid-pinning-actions', + 'igc-grid-toolbar-title', + 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-hiding', + 'igc-grid-toolbar-pinning', + 'igc-grid-toolbar-exporter', + 'igc-grid-toolbar-advanced-filtering', + 'igc-query-builder', + 'igc-query-builder-header' + ]; + const undefinedTags = tags.filter(tag => !customElements.get(tag)); + expect(undefinedTags).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts index 5f5f396f5f7..cb2123bb691 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts @@ -1,7 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ElementRef } from '@angular/core'; import { IgxChartMenuComponent } from './chart-dialog.component'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartMenuComponent', () => { let component: IgxChartMenuComponent; @@ -234,3 +234,110 @@ describe('IgxChartMenuComponent', () => { }); }); }); + +describe('IgxChartMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxChartMenuComponent; + let chartDirective: { chartFactory: ReturnType }; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + const headerButtons = () => queryAll('.action-buttons-wrapper button'); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxChartMenuComponent] }); + fixture = TestBed.createComponent(IgxChartMenuComponent); + component = fixture.componentInstance; + chartDirective = { chartFactory: vi.fn() }; + component.chartDirective = chartDirective; + component.allCharts = ['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']; + component.width = 1000; + component.height = 500; + fixture.detectChanges(); + component.createChart('ColumnStacked'); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create the chart in the chart area and show its title in the header', () => { + expect(chartDirective.chartFactory).toHaveBeenCalledWith('ColumnStacked', component.chartArea); + expect(query('.header-text').textContent!.trim()).toBe('Column Stacked Chart'); + }); + + it('should bind the host size to 70% of the given dimensions and to 100% in full screen', () => { + const host: HTMLElement = fixture.nativeElement; + expect(host.style.width).toBe('700px'); + expect(host.style.height).toBe('350px'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(true); + expect(host.style.width).toBe('1000px'); + expect(host.style.height).toBe('500px'); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen_exit'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(false); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen'); + }); + + it('should emit closed when the close button is clicked', () => { + const closedSpy = vi.fn(); + component.closed.subscribe(closedSpy); + + headerButtons()[1].click(); + + expect(closedSpy).toHaveBeenCalledTimes(1); + }); + + it('should toggle the chart config area with the chevron button', () => { + const configArea = query('.chart-config-area'); + const toggleButton = query('.selection-area > div > button'); + expect(configArea.classList).toContain('opened'); + expect(query('.chart-area').classList).not.toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_right'); + + toggleButton.click(); + fixture.detectChanges(); + + expect(component.isConfigAreaExpanded).toBe(true); + expect(configArea.classList).toContain('closed'); + expect(configArea.classList).not.toContain('opened'); + expect(query('.chart-area').classList).toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_left'); + }); + + it('should render a section only for the main chart types that have available charts', () => { + const sections = queryAll('.wrapper') + .filter(w => w.querySelector('.types-section')) + .map(w => w.firstElementChild!.textContent!.trim()); + + expect(sections).toEqual(['Column Chart', 'Bar Chart', 'Pie Chart']); + }); + + it('should render a button per available chart and mark the current one as selected', () => { + const buttons = queryAll('.types-section-btn'); + + expect(buttons.map(b => b.title)).toEqual(['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']); + expect(buttons[1].classList).toContain('selected'); + expect(buttons[0].classList).not.toContain('selected'); + }); + + it('should create the chosen chart when a chart type button is clicked', () => { + const pieButton = queryAll('.types-section-btn').find(b => b.title === 'Pie')!; + + pieButton.click(); + fixture.detectChanges(); + + expect(chartDirective.chartFactory).toHaveBeenLastCalledWith('Pie', component.chartArea); + expect(component.currentChartType).toBe('Pie'); + expect(pieButton.classList).toContain('selected'); + expect(query('.header-text').textContent!.trim()).toBe('Pie Chart'); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts index ab82c899fbe..9c5662db7f9 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts @@ -1,6 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EventEmitter } from '@angular/core'; import { Subject } from 'rxjs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxOverlayService } from 'igniteui-angular/core'; import { IgxContextMenuComponent } from './context-menu.component'; import { CHART_TYPE } from '../directives/chart-integration/chart-types'; @@ -305,11 +306,104 @@ describe('IgxContextMenuComponent', () => { }); describe('overlayService.opening with chartDialogResizeNotify', () => { + let overlayElement: HTMLElement; + let resizeNotify: Subject; + + const createWrapper = (classes: string[], hidden = false) => { + const wrapper = document.createElement('div'); + wrapper.classList.add(...classes); + if (hidden) { + wrapper.style.visibility = 'hidden'; + } + const content = document.createElement('div'); + wrapper.appendChild(content); + overlayElement.appendChild(wrapper); + return content; + }; + + const openDialogWithResizeNotify = () => { + resizeNotify = new Subject(); + const mockInstance = { chartDialogResizeNotify: resizeNotify, closed: undefined } as any; + (mockOverlay.opening as Subject).next({ id: 'dialog-id', componentRef: { instance: mockInstance } }); + }; + + beforeEach(() => { + overlayElement = document.createElement('div'); + document.body.appendChild(overlayElement); + }); + + afterEach(() => { + overlayElement.remove(); + }); + it('should not throw when opening event has no componentRef', () => { expect(() => { (mockOverlay.opening as Subject).next({ componentRef: undefined }); }).not.toThrow(); }); + + it('should resize the content of the visible flex wrapper to the new dialog width', () => { + const hiddenContent = createWrapper(['igx-overlay__wrapper--flex'], true); + const visibleContent = createWrapper(['igx-overlay__wrapper--flex']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(mockOverlay.getOverlayById).toHaveBeenCalledWith('dialog-id'); + expect(visibleContent.style.width).toBe('420px'); + expect(hiddenContent.style.width).toBe(''); + }); + + it('should ignore wrappers that are not flex overlay wrappers', () => { + const content = createWrapper(['igx-overlay__wrapper']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(content.style.width).toBe(''); + }); + + it('should not throw when the flex wrapper has no content element', () => { + const wrapper = document.createElement('div'); + wrapper.classList.add('igx-overlay__wrapper--flex'); + overlayElement.appendChild(wrapper); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + + it('should not throw when the overlay has no element', () => { + mockOverlay.getOverlayById.mockReturnValue({ elementRef: undefined }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + }); + + describe('overlayService.closing subscription without an open dialog', () => { + it('should not reopen tabsMenu when the chart dialog closes and no dialog id is tracked', () => { + (mockOverlay.closing as Subject).next({ + componentRef: { instance: TestBed.createComponent(IgxChartMenuComponent).componentInstance } + }); + + expect(mockTabsMenu.open).not.toHaveBeenCalled(); + }); + }); + + describe('without optional chart and formatting directives', () => { + it('should initialize from the context directive without subscribing to missing directives', () => { + mockContextDirective.chartsDirective = null; + mockContextDirective.textFormatter = null; + mockContextDirective.charts = []; + mockContextDirective.formatters = []; + + expect(() => component.ngAfterViewInit()).not.toThrow(); + expect(component.chartTypes).toEqual([]); + expect(component.textFormatters).toEqual([]); + }); }); describe('constructor', () => { @@ -332,3 +426,231 @@ describe('IgxContextMenuComponent', () => { }); }); }); + +describe('IgxContextMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxContextMenuComponent; + let overlay: IgxOverlayService; + let contextDirective: any; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + + const createComponent = async (overrides: any = {}) => { + contextDirective = { + charts: [CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped, CHART_TYPE.Pie], + formatters: ['Top 10', 'Duplicate Values'], + displayCreationTab: true, + chartsDirective: { + chartTypesDetermined: new EventEmitter(), + chartFactory: vi.fn() + }, + textFormatter: { + formattersReady: new EventEmitter(), + formatCells: vi.fn(), + clearFormatting: vi.fn(), + formatter: undefined + }, + buttonClose: new EventEmitter(), + gridResizeNotify: new Subject(), + grid: { nativeElement: document.createElement('div') }, + ...overrides + }; + fixture = TestBed.createComponent(IgxContextMenuComponent); + component = fixture.componentInstance; + component.contextDirective = contextDirective; + // ngAfterViewInit copies these from the context directive after the view is checked; + // seed the same values up front so the first check is stable in dev mode + component.chartTypes = contextDirective.charts; + component.textFormatters = contextDirective.formatters; + component.displayCreationTab = contextDirective.displayCreationTab; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + const selectChartsTab = async () => { + component.tabs.selectedIndex = 1; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxContextMenuComponent, NoopAnimationsModule] }); + overlay = TestBed.inject(IgxOverlayService); + }); + + afterEach(() => { + component?.closeDialog(); + TestBed.resetTestingModule(); + }); + + it('should toggle the tabs menu when the analytics button is clicked', async () => { + await createComponent(); + expect(component.tabsMenu.collapsed).toBe(true); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(false); + expect(component.currentChartType).toBe(CHART_TYPE.ColumnGrouped); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should close an open tabs menu when the grid is resized', async () => { + await createComponent(); + component.toggleTabMenu(); + expect(component.tabsMenu.collapsed).toBe(false); + + contextDirective.gridResizeNotify.next(); + + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should not close the tabs menu on grid resize when it is already closed', async () => { + await createComponent(); + const closeSpy = vi.spyOn(component.tabsMenu, 'close'); + + contextDirective.gridResizeNotify.next(); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should render a formatting option per formatter and apply it on click', async () => { + await createComponent(); + const conditions = queryAll('.condition'); + + expect(conditions.length).toBe(2); + expect(conditions[0].querySelector('.name')!.textContent!.trim()).toBe('Top 10%'); + expect(conditions[1].querySelector('.name')!.textContent!.trim()).toBe('Duplicate Values'); + + conditions[1].click(); + fixture.detectChanges(); + + expect(contextDirective.textFormatter.formatCells).toHaveBeenCalledWith('Duplicate Values'); + expect(conditions[1].classList).toContain('selected--condition'); + expect(conditions[0].classList).not.toContain('selected--condition'); + }); + + it('should disable "Clear All" until a formatter is applied and clear formatting on click', async () => { + await createComponent(); + const clear = query('.clear'); + expect(clear.classList).toContain('disableButton'); + + contextDirective.textFormatter.formatter = 'Top 10'; + fixture.detectChanges(); + expect(clear.classList).not.toContain('disableButton'); + + component.currentFormatter = 'Top 10'; + clear.click(); + + expect(contextDirective.textFormatter.clearFormatting).toHaveBeenCalled(); + expect(component.currentFormatter).toBeUndefined(); + }); + + it('should disable "Clear All" when there is no conditional formatting directive', async () => { + await createComponent({ textFormatter: null }); + + expect(query('.clear').classList).toContain('disableButton'); + }); + + it('should render at most four chart options and mark the current chart as selected', async () => { + await createComponent(); + component.currentChartType = CHART_TYPE.BarGrouped; + await selectChartsTab(); + + const charts = queryAll('.action.chart'); + expect(charts.length).toBe(4); + expect(charts.map(c => c.querySelector('.name')!.textContent!.trim())) + .toEqual([CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped]); + expect(charts[1].classList).toContain('selected'); + expect(charts[0].classList).not.toContain('selected'); + expect(query('.more-action').classList).not.toContain('disableButton'); + }); + + it('should preview a chart on mouseenter and hide the preview on mouseleave', async () => { + await createComponent(); + component.toggleTabMenu(); + await selectChartsTab(); + const chart = queryAll('.action.chart')[2]; + + chart.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + + expect(component.currentChartType).toBe(CHART_TYPE.LineGrouped); + expect(contextDirective.chartsDirective.chartFactory).toHaveBeenCalledWith(CHART_TYPE.LineGrouped, component.chartPreview); + expect(component.chartPreviewDialog.collapsed).toBe(false); + + chart.dispatchEvent(new MouseEvent('mouseleave')); + fixture.detectChanges(); + + expect(component.chartPreviewDialog.collapsed).toBe(true); + }); + + it('should open the chart dialog configured from the context when a chart option is clicked', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + component.toggleTabMenu(); + await selectChartsTab(); + + queryAll('.action.chart')[1].click(); + fixture.detectChanges(); + + // the first opening is the tabs menu toggle, which has no componentRef + const dialogArgs = openingSpy.mock.calls.map(c => c[0]).find(args => args.componentRef); + const dialog = dialogArgs.componentRef.instance as IgxChartMenuComponent; + expect(dialog).toBeInstanceOf(IgxChartMenuComponent); + expect(dialog.currentChartType).toBe(CHART_TYPE.BarGrouped); + expect(dialog.allCharts).toBe(component.chartTypes); + expect(dialog.chartDirective).toBe(contextDirective.chartsDirective); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should open the dialog with the current chart type from "More..." and close it via the dialog close event', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + await selectChartsTab(); + component.currentChartType = CHART_TYPE.Pie; + + query('.more-action').click(); + fixture.detectChanges(); + + const dialogInfo = openingSpy.mock.calls[0][0]; + const dialog = dialogInfo.componentRef.instance as IgxChartMenuComponent; + expect(dialog.currentChartType).toBe(CHART_TYPE.Pie); + expect(overlay.getOverlayById(dialogInfo.id)).toBeDefined(); + + dialog.closed.emit(); + + expect(overlay.getOverlayById(dialogInfo.id)).toBeFalsy(); + }); + + it('should disable the chart creation tab when there are no charts', async () => { + await createComponent({ charts: [] }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(2); + expect(headers[0].getAttribute('aria-disabled')).toBe('false'); + expect(headers[1].getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not render the chart creation tab when displayCreationTab is false', async () => { + await createComponent({ displayCreationTab: false }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(1); + expect(headers[0].textContent!.trim()).toBe('Conditional Formatting'); + }); + + it('should select the chart creation tab when there are charts but no formatters', async () => { + await createComponent({ formatters: [] }); + + expect(component.tabs.selectedIndex).toBe(1); + expect(queryAll('.action.chart').length).toBe(4); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts index b13a0a2f872..886f12973fd 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts @@ -633,4 +633,120 @@ describe('IgxContextMenuDirective', () => { expect(mockOverlay.hide).toHaveBeenCalledWith('some-id'); }); }); + + describe('grid resize observer', () => { + it('should observe the grid element and forward resize notifications to gridResizeNotify', () => { + let observerCallback: () => void; + const observe = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { + observerCallback = cb; + } + public observe = observe; + public disconnect = vi.fn(); + }); + directive.ngOnDestroy(); + const fresh = TestBed.runInInjectionContext(() => new IgxContextMenuDirective()); + fresh.ngOnInit(); + fresh.ngAfterViewInit(); + const resizeSpy = vi.fn(); + fresh.gridResizeNotify.subscribe(resizeSpy); + + observerCallback!(); + + expect(observe).toHaveBeenCalledWith(mockGrid.nativeElement); + expect(resizeSpy).toHaveBeenCalledTimes(1); + fresh.ngOnDestroy(); + vi.unstubAllGlobals(); + }); + }); + + describe('column selection while the button is visible', () => { + const headerElement = document.createElement('div'); + + beforeEach(() => { + mockGrid.selectedColumns.mockReturnValue([ + { visibleIndex: 0, headerCell: { nativeElement: headerElement } } + ]); + (directive as any)._collapsed = false; + (directive as any)._id = 'some-id'; + }); + + it('should emit buttonClose without hiding when the selection change has no old selection', () => { + const closeSpy = vi.fn(); + directive.buttonClose.subscribe(closeSpy); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(mockOverlay.hide).not.toHaveBeenCalled(); + }); + + it('should reposition the existing overlay at the header cell and update its position strategy', () => { + const info = { settings: { positionStrategy: undefined } }; + mockOverlay.getOverlayById.mockReturnValue(info); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(mockOverlay.reposition).toHaveBeenCalledWith('some-id'); + expect(mockOverlay.show).not.toHaveBeenCalled(); + expect(info.settings.positionStrategy).toBe((directive as any)._analyticsBtnSettings.positionStrategy); + expect((directive as any)._analyticsBtnSettings.target).toBe(headerElement); + }); + }); + + describe('without optional chart and formatting directives', () => { + let bare: IgxContextMenuDirective; + + beforeEach(() => { + // detach the default directive so only the bare instance reacts to grid events + directive.ngOnDestroy(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IgxContextMenuDirective, + { provide: IgxGridComponent, useValue: mockGrid }, + { provide: IgxOverlayService, useValue: mockOverlay } + ] + }); + bare = TestBed.inject(IgxContextMenuDirective); + bare.ngOnInit(); + bare.ngAfterViewInit(); + }); + + afterEach(() => { + bare.ngOnDestroy(); + }); + + it('should have null textFormatter and chartsDirective', () => { + expect(bare.textFormatter).toBeNull(); + expect(bare.chartsDirective).toBeNull(); + expect(bare.formatters).toEqual([]); + expect(bare.charts).toEqual([]); + }); + + it('should still handle range selection and show the button', () => { + mockGrid.gridAPI.get_cell_by_index.mockReturnValue({ + row: { key: 0 }, + column: { index: 1 }, + nativeElement: document.createElement('div') + }); + + mockGrid.rangeSelected.next({ rowStart: 0, rowEnd: 1, columnStart: 0, columnEnd: 0 }); + vi.advanceTimersByTime(200); + + expect(mockGrid.getSelectedData).not.toHaveBeenCalled(); + expect(mockOverlay.show).toHaveBeenCalledWith('overlay-id'); + }); + + it('should still clear cell selection on column selection', () => { + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'], oldSelection: [] }); + vi.advanceTimersByTime(100); + + expect(mockGrid.clearCellSelection).toHaveBeenCalled(); + expect(mockGrid.getSelectedColumnsData).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts index c1dfc9dcd98..f4ecadfd707 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts @@ -1,6 +1,14 @@ +import { Component, ViewChild, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxColumnSeriesComponent, IgxDataChartCategoryModule, IgxDataChartComponent, + IgxDataChartCoreModule, IgxDataChartScatterModule, IgxDataChartStackedModule, IgxItemLegendComponent, + IgxItemLegendModule, IgxLegendComponent, IgxLegendModule, IgxNumericXAxisModule, IgxNumericYAxisModule, + IgxPieChartComponent, IgxPieChartModule, IgxStacked100BarSeriesComponent, IgxStackedColumnSeriesComponent +} from 'igniteui-angular-charts'; import { CHART_TYPE, OPTIONS_TYPE } from './chart-types'; import { IgxChartIntegrationDirective, IDeterminedChartTypesArgs } from './chart-integration.directive'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartIntegrationDirective', () => { let directive: IgxChartIntegrationDirective; @@ -442,4 +450,111 @@ describe('IgxChartIntegrationDirective', () => { expect(result).toBeDefined(); }); }); + + describe('chartFactory with a ViewContainerRef', () => { + @Component({ + template: ``, + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxDataChartScatterModule, IgxPieChartModule, IgxLegendModule, IgxItemLegendModule, + IgxNumericXAxisModule, IgxNumericYAxisModule, IgxCategoryXAxisModule, IgxCategoryYAxisModule + ] + }) + class ChartHostComponent { + @ViewChild('host', { read: ViewContainerRef, static: true }) + public host!: ViewContainerRef; + } + + let fixture: ComponentFixture; + let vcr: ViewContainerRef; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChartHostComponent] }); + fixture = TestBed.createComponent(ChartHostComponent); + fixture.detectChanges(); + vcr = fixture.componentInstance.host; + directive.chartData = [{ name: 'A', q1: 10, q2: 5 }, { name: 'B', q1: 20, q2: 15 }]; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create a pie chart with an item legend', () => { + let emitted: any; + directive.chartCreationDone.subscribe(c => emitted = c); + + const chart = directive.chartFactory(CHART_TYPE.Pie, vcr); + + expect(chart).toBeInstanceOf(IgxPieChartComponent); + expect(emitted).toBe(chart); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxItemLegendComponent); + expect(chart.valueMemberPath).toBe('q1'); + expect(chart.labelMemberPath).toBe('name'); + }); + + it('should create a data chart with one series per numeric column and a legend', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxLegendComponent); + expect(chart.series.count).toBe(2); + expect(chart.series.item(0)).toBeInstanceOf(IgxColumnSeriesComponent); + expect(chart.series.item(1).valueMemberPath).toBe('q2'); + expect(chart.axes.count).toBe(2); + }); + + it('should not create a legend when useLegend is false', () => { + directive.useLegend = false; + + const chart = directive.chartFactory(CHART_TYPE.LineGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(1); + expect(chart.legend).toBeFalsy(); + }); + + it('should create one stacked series with a fragment per numeric column', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('q1'); + expect(series.series.item(0).title).toBe('q1'); + expect(series.series.item(1).valueMemberPath).toBe('q2'); + }); + + it('should not apply the series model to stacked fragments', () => { + directive.setChartComponentOptions(CHART_TYPE.ColumnStacked, OPTIONS_TYPE.Series, { title: 'custom' }); + + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + const fragment = chart.series.item(0).series.item(0); + expect(fragment.title).toBe('q1'); + }); + + it('should use the label member path on the y axis for stacked bar charts', () => { + const chart = directive.chartFactory(CHART_TYPE.Bar100Stacked, vcr); + + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStacked100BarSeriesComponent); + expect(series.yAxis.label).toBe('name'); + expect(series.xAxis.label).toBeFalsy(); + }); + + it('should configure the bubble size scale when creating in a container', () => { + directive.chartData = [{ name: 'A', x: 1, y: 2, r: 3 }, { name: 'B', x: 4, y: 5, r: 6 }]; + + const chart = directive.chartFactory(CHART_TYPE.ScatterBubble, vcr); + + const series = chart.series.item(0); + expect(series.radiusMemberPath).toBe('y'); + expect(series.radiusScale.minimumValue).toBe(10); + expect(series.radiusScale.maximumValue).toBe(60); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts index 48681021e4e..2acb949af6a 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { IgxPieChartInitializer, IgxDataChartInitializer, @@ -13,9 +14,17 @@ import { IgxScatterLineSeriesComponent, IgxStackedColumnSeriesComponent, IgxStackedBarSeriesComponent, - IgxStacked100BarSeriesComponent + IgxStacked100BarSeriesComponent, + IgxDataChartComponent, + IgxDataChartCoreModule, + IgxDataChartCategoryModule, + IgxDataChartStackedModule, + IgxCategoryXAxisModule, + IgxCategoryYAxisModule, + IgxNumericXAxisModule, + IgxNumericYAxisModule } from 'igniteui-angular-charts'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Helper to create a mock chart with series and axes arrays function createMockDataChart() { @@ -220,4 +229,82 @@ describe('IgxStackedDataChartInitializer', () => { const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); expect(init.seriesType).toBe(IgxStackedBarSeriesComponent); }); + + describe('initChart', () => { + const stackedOptions = (): IChartComponentOptions => ({ + chartOptions: { width: '100%' }, + stackedFragmentOptions: [ + { valueMemberPath: 'val1', title: 'V1' }, + { valueMemberPath: 'val2', title: 'V2' } + ] as any, + xAxisOptions: { label: 'name' }, + yAxisOptions: { labelExtent: 40 } + }); + + let chart: IgxDataChartComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxNumericXAxisModule, IgxNumericYAxisModule + ] + }); + chart = TestBed.createComponent(IgxDataChartComponent).componentInstance; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should add a single stacked series with one fragment per fragment option', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('val1'); + expect(series.series.item(0).title).toBe('V1'); + expect(series.series.item(1).valueMemberPath).toBe('val2'); + }); + + it('should bind the series to the initializer axes and add them to the chart', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series.xAxis).toBe((init as any).xAxis); + expect(series.yAxis).toBe((init as any).yAxis); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + expect(chart.axes.item(1)).toBe((init as any).yAxis); + expect((init as any).xAxis.label).toBe('name'); + expect((init as any).yAxis.labelExtent).toBe(40); + }); + + it('should apply chart options and return the same chart instance', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + const result = init.initChart(chart, stackedOptions()); + + expect(result).toBe(chart); + expect(chart.width).toBe('100%'); + }); + + it('should replace existing series and axes when re-initializing a chart', () => { + new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent).initChart(chart, stackedOptions()); + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + expect(chart.series.item(0)).toBeInstanceOf(IgxStackedBarSeriesComponent); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts index 60d9009d267..c7775106c04 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts @@ -648,4 +648,78 @@ describe('IgxConditionalFormattingDirective', () => { expect(directive.formatColors.text).toBe('#FFF'); }); }); + + describe('duplicates and uniques with text values and out-of-range cells', () => { + beforeEach(() => { + (directive as any).formatedRange = new Map([[0, new Set([0])]]); + }); + + it('should return undefined color for duplicates when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 10 }]); + expect(directive.duplicates.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should return undefined color for uniques when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }]); + expect(directive.uniques.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should compare text values only against text data for duplicates', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.duplicates.backgroundColor(null, 0, 'A', 0)).toBe(directive.formatColors.info); + expect(directive.duplicates.color(null, 0, 'B', 0)).toBe(''); + }); + + it('should compare text values only against text data for uniques', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.uniques.backgroundColor(null, 0, 'B', 0)).toBe(directive.formatColors.info); + expect(directive.uniques.color(null, 0, 'A', 0)).toBe(''); + }); + }); + + describe('formatCells range handling', () => { + let col0: any, col1: any, col2: any; + + beforeEach(() => { + col0 = { visibleIndex: 0, cellStyles: undefined }; + col1 = { visibleIndex: 1, cellStyles: undefined }; + col2 = { visibleIndex: 2, cellStyles: undefined }; + mockGrid.visibleColumns = [col0, col1, col2]; + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 20 }]); + mockGrid.data = [{ val: 10 }, { val: 20 }]; + }); + + it('should use the provided formatRange instead of the grid selected ranges', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + const customRange: any = [{ rowStart: 1, rowEnd: 1, columnStart: 2, columnEnd: 2 }]; + + directive.formatCells(ConditionalFormattingType.ColorScale, customRange); + + expect(directive.isWithInFormattedRange(1, 2)).toBe(true); + expect(directive.isWithInFormattedRange(0, 0)).toBe(false); + }); + + it('should keep the existing formatted range when reset is false', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + directive.formatCells(ConditionalFormattingType.ColorScale); + + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 1, rowEnd: 1, columnStart: 1, columnEnd: 1 }]); + directive.formatCells(ConditionalFormattingType.Empty, undefined, false); + + expect(directive.isWithInFormattedRange(0, 0)).toBe(true); + expect(directive.isWithInFormattedRange(1, 1)).toBe(false); + expect(directive.formatter).toBe(ConditionalFormattingType.Empty); + }); + + it('should not notify the grid when no visible column falls into the range', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 1, columnStart: 5, columnEnd: 6 }]); + + directive.formatCells(ConditionalFormattingType.ColorScale); + + expect(mockGrid.notifyChanges).not.toHaveBeenCalled(); + expect(col0.cellStyles).toBeNull(); + expect(col1.cellStyles).toBeNull(); + expect(col2.cellStyles).toBeNull(); + }); + }); }); From f8eb6d1ea8ca333aedb2e7772ec413d557038f19 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:16:59 +0300 Subject: [PATCH 02/22] fix(tree-grid): skip autogenerate when data is set before init Setting data on a tree grid with autoGenerate before ngOnInit threw, since the grid API service has no grid yet. Columns are generated on init instead, matching the grid and hierarchical grid. Co-Authored-By: Claude Opus 5.5 --- .../grids/tree-grid/src/tree-grid.component.spec.ts | 11 +++++++++++ .../grids/tree-grid/src/tree-grid.component.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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(); From 8e3119671208c6e4892c3eb0345d1885adb9a3a1 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:17:00 +0300 Subject: [PATCH 03/22] fix(elements): apply writable props, filter factory and icon actions - Set writable additional properties (e.g. igc-column selected) on the instance, since they are not inputs and setInput ignored them - Add the deprecated getFilterFactory to igc-grid, which extends the base grid component instead of being it - Only process the icon broadcast message part for its action type - Return proxies, not result wrappers, for component arrays in events Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ .../src/app/create-custom-element.spec.ts | 27 +++++++++++- .../src/app/create-custom-element.ts | 3 +- .../src/app/custom-strategy.spec.ts | 44 ++++++++++++++++--- .../src/app/custom-strategy.ts | 11 ++++- .../src/lib/icon.broadcast.service.spec.ts | 21 +++++++++ .../src/lib/icon.broadcast.service.ts | 4 +- 7 files changed, 104 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8297582c72..1b0dd0ef6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,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. ## 22.2.3 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 add696e8bc6..dfe0dbd4129 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 { 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'; @@ -590,12 +590,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 +686,22 @@ 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; @@ -783,6 +799,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; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index dd27277d4e3..7e09a7b2e69 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -296,6 +296,13 @@ 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; + componentRef.changeDetectorRef.markForCheck(); + this.notifyChanges(); + return; + } super.setInputValue(property, value); } @@ -508,8 +515,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!); } } From fa05665470ed038184980aac237773951e147e51 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:03:04 +0300 Subject: [PATCH 04/22] test(elements, extras): adding more tests for additional coverage --- .../src/app/create-custom-element.spec.ts | 160 +++++++++ .../src/app/custom-strategy-deferred.spec.ts | 48 +++ .../src/app/custom-strategy.spec.ts | 326 +++++++++++++++++- .../src/app/ng-element-strategy.spec.ts | 187 ++++++++++ .../src/app/wrapper/wrapper.component.spec.ts | 87 ++++- .../src/lib/exporters.spec.ts | 63 ++++ .../src/lib/icon.broadcast.service.spec.ts | 50 ++- .../src/lib/state.component.spec.ts | 148 ++++++++ .../src/utils/register.spec.ts | 82 +++++ .../chart-dialog.component.spec.ts | 111 +++++- .../context-menu.component.spec.ts | 324 ++++++++++++++++- .../igx-context-menu.directive.spec.ts | 116 +++++++ .../chart-integration.directive.spec.ts | 117 ++++++- .../chart-integration/initializers.spec.ts | 91 ++++- .../conditional-formatting.directive.spec.ts | 74 ++++ 15 files changed, 1972 insertions(+), 12 deletions(-) create mode 100644 projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/exporters.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/state.component.spec.ts create mode 100644 projects/igniteui-angular-elements/src/utils/register.spec.ts 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 new file mode 100644 index 00000000000..1cbc3feab70 --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts @@ -0,0 +1,160 @@ +import { Component, Input } from '@angular/core'; +import { + FilteringExpressionsTree, + FilteringLogic, + IgxBooleanFilteringOperand, + IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxGridComponent, + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxTimeFilteringOperand +} from 'igniteui-angular'; +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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-custom-element-test', + template: `{{ value }}`, + standalone: true +}) +class CustomElementTestComponent { + @Input() + public value: string; + + @Input() + public showUI: boolean; + + public counter = 0; + public readonlyProp = 'readonly'; + public writableProp = 'writable'; + + public increment(by: number) { + this.counter += by; + return this.counter; + } +} + +const TEST_TAG = 'igc-custom-element-test'; + +const createConfig = (overrides: Partial = {}): ComponentConfig[] => [{ + component: CustomElementTestComponent, + selector: TEST_TAG, + parents: [], + contentQueries: [], + additionalProperties: [ + { name: 'readonlyProp' }, + { name: 'writableProp', writable: true } + ], + methods: ['increment'], + ...overrides +}]; + +describe('createIgxCustomElement', () => { + const elementCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + if (!customElements.get(TEST_TAG)) { + customElements.define(TEST_TAG, elementCtor); + } + + let element: IgcNgElement & CustomElementTestComponent; + + beforeEach(() => { + element = document.createElement(TEST_TAG) as IgcNgElement & CustomElementTestComponent; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + vi.restoreAllMocks(); + }); + + const getInstance = async () => + (await element.ngElementStrategy[ComponentRefKey]).instance as CustomElementTestComponent; + + it('should expose the configured selector as static tagName', () => { + expect(elementCtor.tagName).toBe(TEST_TAG); + expect(Object.getOwnPropertyDescriptor(elementCtor, 'tagName').writable).toBe(false); + }); + + it('should proxy configured methods to the component instance and schedule change detection', async () => { + const instance = await getInstance(); + const notifySpy = vi.spyOn(element.ngElementStrategy, 'notifyChanges'); + + expect(element.increment(2)).toBe(2); + expect(element.increment(3)).toBe(5); + + expect(instance.counter).toBe(5); + expect(notifySpy).toHaveBeenCalledTimes(2); + }); + + it('should expose additional properties as getters on the element', async () => { + const instance = await getInstance(); + + expect(element.readonlyProp).toBe('readonly'); + instance.readonlyProp = 'changed'; + expect(element.readonlyProp).toBe('changed'); + }); + + it('should only allow setting writable additional properties', async () => { + const instance = await getInstance(); + const setInputSpy = vi.spyOn(element.ngElementStrategy, 'setInputValue').mockImplementation(() => {}); + + element.writableProp = 'updated'; + expect(setInputSpy).toHaveBeenCalledExactlyOnceWith('writableProp', 'updated'); + + expect(() => (element as any).readonlyProp = 'nope').toThrow(TypeError); + expect(instance.readonlyProp).toBe('readonly'); + }); + + it('should throw for additional properties that clash with the element prototype', () => { + const config = createConfig({ additionalProperties: [{ name: 'connectedCallback' }] }); + + expect(() => createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: config })) + .toThrowError(`Potentially illegal property name connectedCallback defined for ${CustomElementTestComponent.name}`); + }); + + it('should add the deprecated getFilterFactory to IgxGridComponent elements', () => { + const config: ComponentConfig[] = [{ + component: IgxGridComponent, + selector: 'igc-filter-factory-test-grid', + parents: [], + contentQueries: [], + additionalProperties: [], + methods: [] + }]; + const gridCtor = createIgxCustomElement(IgxGridComponent, { injector, registerConfig: config }); + const otherCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + + expect(otherCtor.prototype.getFilterFactory).toBeUndefined(); + + const factory = gridCtor.prototype.getFilterFactory(); + expect(factory.stringFilteringOperand).toBe(IgxStringFilteringOperand.instance()); + expect(factory.numberFilteringOperand).toBe(IgxNumberFilteringOperand.instance()); + expect(factory.timeFilteringOperand).toBe(IgxTimeFilteringOperand.instance()); + expect(factory.dateTimeFilteringOperand).toBe(IgxDateTimeFilteringOperand.instance()); + expect(factory.dateFilteringOperand).toBe(IgxDateFilteringOperand.instance()); + expect(factory.booleanFilteringOperand).toBe(IgxBooleanFilteringOperand.instance()); + + const tree = factory.createFilteringExpressionTree(FilteringLogic.Or, 'Name'); + expect(tree).toBeInstanceOf(FilteringExpressionsTree); + expect(tree.operator).toBe(FilteringLogic.Or); + expect(tree.fieldName).toBe('Name'); + }); +}); + +describe('withRegister', () => { + it('should attach a non-writable static register function', () => { + const ctor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + const register = vi.fn(); + + const component = withRegister(ctor, register); + component.register(); + + expect(component).toBe(ctor); + expect(register).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(component, 'register').writable).toBe(false); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts new file mode 100644 index 00000000000..a008cb8ff8a --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -0,0 +1,48 @@ +import { firstValueFrom, fromEvent } from 'rxjs'; +import { ComponentRefKey, IgcNgElement } from './custom-strategy'; +import { IgcColumnComponent, IgcTreeGridComponent } from './components'; +import { defineComponents } from '../utils/register'; +import { IgxTreeGridComponent } from '../lib/grids/tree-grid.component'; +import { describe, it, expect } from 'vitest'; + +describe('Elements: deferred parent definition', () => { + it('should wait for the parent element to be defined before attaching to it', async () => { + // Relies on the per-file isolation of the test runner for a clean custom elements registry + expect(customElements.get('igc-tree-grid')).toBeUndefined(); + defineComponents(IgcColumnComponent); + + const container = document.createElement('div'); + document.body.appendChild(container); + try { + container.innerHTML = ` + + + + `; + const treeGridEl = container.querySelector('#treeGrid'); + const columnEls = Array.from(container.querySelectorAll('igc-column')); + + // columns connect first, but have to wait for their parent to be defined + let columnsResolved = false; + const columnRefs = Promise.all(columnEls.map(c => c.ngElementStrategy[ComponentRefKey])) + .then(refs => { + columnsResolved = true; + return refs; + }); + await Promise.resolve(); + expect(columnsResolved).toBe(false); + + const childrenResolved = firstValueFrom(fromEvent(treeGridEl, 'childrenResolved')); + defineComponents(IgcTreeGridComponent); + + const treeGrid = (await (treeGridEl as IgcNgElement).ngElementStrategy[ComponentRefKey]).instance as IgxTreeGridComponent; + const columns = (await columnRefs).map(ref => ref.instance); + await childrenResolved; + + expect(treeGrid.columnList.toArray()).toEqual(columns); + expect(treeGrid.columns.map(c => c.field)).toEqual(['ID', 'Name']); + } finally { + container.remove(); + } + }); +}); 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 8e1a286b23b..add696e8bc6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,5 +1,5 @@ import { ApplicationRef, ViewContainerRef } from '@angular/core'; -import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType } from 'igniteui-angular'; +import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; @@ -23,9 +23,11 @@ import { IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, IgcGridToolbarHidingComponent, + IgcTreeGridComponent, } from './components'; +import { TemplateRefWrapper } from './wrapper/template-ref-wrapper'; import { defineComponents } from '../utils/register'; -import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; +import { describe, beforeAll, beforeEach, afterEach, it, expect, vi } from 'vitest'; describe('Elements: ', () => { let testContainer: HTMLDivElement; @@ -47,7 +49,8 @@ describe('Elements: ', () => { IgcGridToolbarActionsComponent, IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, - IgcGridToolbarHidingComponent + IgcGridToolbarHidingComponent, + IgcTreeGridComponent ); }); @@ -581,5 +584,322 @@ describe('Elements: ', () => { highlightedCell = gridEl.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); expect(highlightedCell).toBeNull(); }); + + it('should emit columnsAutogenerated and selectedRowsChange for tree grid', async () => { + const treeGridEl = document.createElement('igc-tree-grid') as IgcNgElement & InstanceType; + treeGridEl.autoGenerate = true; + treeGridEl.primaryKey = 'ID'; + treeGridEl.childDataKey = 'Employees'; + + 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']); + + const selectionChanged = firstValueFrom(fromEvent(treeGridEl, 'selectedRowsChange')); + treeGridEl.selectRows([147]); + expect((await selectionChanged).detail).toEqual([147]); + }); + + it('should emit columnsAutogenerated and selectedRowsChange for grid', async () => { + const gridEl = document.createElement('igc-grid'); + gridEl.autoGenerate = true; + gridEl.primaryKey = 'ProductID'; + gridEl.data = SampleTestData.foodProductData(); + const autoGenerated = firstValueFrom(fromEvent(gridEl, 'columnsAutogenerated')); + + testContainer.appendChild(gridEl); + + const columns = (await autoGenerated).detail.columns as IgxColumnComponent[]; + expect(columns.map(c => c.field)).toContain('ProductName'); + + const selectionChanged = firstValueFrom(fromEvent(gridEl, 'selectedRowsChange')); + gridEl.selectRows([1, 2]); + expect((await selectionChanged).detail).toEqual([1, 2]); + }); + }); + + describe('Element property handling.', () => { + let gridEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + + beforeEach(async () => { + gridEl = document.createElement('igc-grid') as IgcNgElement & InstanceType; + testContainer.appendChild(gridEl); + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + }); + + it('should wrap template functions in template refs and return the original function', async () => { + const emptyTemplate = () => html`Nothing here`; + + gridEl.emptyGridTemplate = emptyTemplate as any; + + const templateRef = grid.emptyGridTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(gridEl.emptyGridTemplate).toBe(emptyTemplate); + + // setting the same function again should keep the same template ref + gridEl.emptyGridTemplate = emptyTemplate as any; + expect(grid.emptyGridTemplate).toBe(templateRef); + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(gridEl.querySelector('.test-empty')?.textContent).toBe('Nothing here'); + + const otherTemplate = () => html`Still nothing`; + gridEl.emptyGridTemplate = otherTemplate as any; + expect(grid.emptyGridTemplate).not.toBe(templateRef); + expect(gridEl.emptyGridTemplate).toBe(otherTemplate); + }); + + it('should tie Elements rendered in templates to the component lifecycle', async () => { + gridEl.emptyGridTemplate = (() => html``) as any; + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + + const templatePaginator = gridEl.querySelector('#templatePaginator'); + expect(templatePaginator).toBeTruthy(); + expect((templatePaginator.ngElementStrategy as any).angularParent.instance).toBe(grid); + }); + + it('should coerce bool and numeric props', 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; + + (columnEl as any).hidden = 'true'; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = 'false'; + expect(column.hidden).toBe(false); + (columnEl as any).hidden = ''; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = null; + expect(column.hidden).toBe(false); + + (columnEl as any).rowStart = '2'; + expect(column.rowStart).toBe(2); + (columnEl as any).rowStart = 'not a number'; + expect(column.rowStart).toBe('not a number'); + }); + + it('should hide opened toggle components in the grid on grid scroll', () => { + const createToggle = () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + return toggle; + }; + const toggle = createToggle(); + const nonToggle = createToggle(); + const detachedToggle = createToggle(); + + const scroll = () => gridEl.dispatchEvent(new CustomEvent('gridScroll')); + const open = (el: HTMLElement, detail = {}) => el.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail })); + + // nothing opened, nothing to hide + scroll(); + + open(toggle); + open(nonToggle, { owner: 'combo' }); + open(detachedToggle); + detachedToggle.remove(); + scroll(); + + expect(toggle.hide).toHaveBeenCalledTimes(1); + expect(nonToggle.hide).not.toHaveBeenCalled(); + expect(detachedToggle.hide).not.toHaveBeenCalled(); + + // opened toggles are cleared after hiding + scroll(); + expect(toggle.hide).toHaveBeenCalledTimes(1); + }); + + it('should stop handling grid popups once the grid is destroyed', async () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + toggle.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail: {} })); + + gridEl.remove(); + await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + gridEl.dispatchEvent(new CustomEvent('gridScroll')); + + expect(toggle.hide).not.toHaveBeenCalled(); + }); + }); + + describe('Event args component proxies.', () => { + let gridEl: IgcNgElement & InstanceType; + let strategy: any; + let column: IgxColumnComponent; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + strategy = gridEl.ngElementStrategy; + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + column = gridEl.columns[0] as IgxColumnComponent; + }); + + it('should wrap components with template props in a proxy that converts templates', () => { + const { value: proxy, hasProxies } = strategy.createProxyForComponentValue(column, 1); + expect(hasProxies).toBe(true); + expect(proxy).not.toBe(column); + + const headerTemplate = () => html`Header`; + proxy.headerTemplate = headerTemplate; + const templateRef = column.headerTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(strategy.templateWrapper.getTemplateFunction(templateRef)).toBe(headerTemplate); + + // same template function keeps the existing ref + proxy.headerTemplate = headerTemplate; + expect(column.headerTemplate).toBe(templateRef); + + // non-template props are set as is + proxy.header = 'Custom header'; + expect(column.header).toBe('Custom header'); + }); + + it('should replace components nested in event args objects with proxies', () => { + const args = { column, owner: null, nested: { column } }; + + const { value, hasProxies } = strategy.createProxyForComponentValue(args, 1); + + expect(hasProxies).toBe(true); + expect(value).toBe(args); + expect(value.owner).toBeNull(); + expect(value.column).not.toBe(column); + expect(value.column.field).toBe(column.field); + expect(value.nested.column).not.toBe(column); + }); + + 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; + const event = new Event('test'); + const deep = { a: { b: { c: { column } } } }; + const plainArray = [{ field: 'ProductID' }]; + + expect(strategy.createProxyForComponentValue(column, 4)).toEqual({ value: column, hasProxies: false }); + expect(strategy.createProxyForComponentValue(event, 1)).toEqual({ value: event, hasProxies: false }); + expect(strategy.createProxyForComponentValue([], 1)).toEqual({ value: [], hasProxies: false }); + expect(strategy.createProxyForComponentValue(plainArray, 1)).toEqual({ value: plainArray, hasProxies: false }); + expect(strategy.createProxyForComponentValue(paginatorInstance, 1)).toEqual({ value: paginatorInstance, hasProxies: false }); + + const { hasProxies } = strategy.createProxyForComponentValue(deep, 1); + expect(hasProxies).toBe(false); + expect(deep.a.b.c.column).toBe(column); + }); + + it('should only patch columnInit 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); + }); + }); + + describe('Hierarchical grid integration.', () => { + let hgridEl: IgcNgElement & InstanceType; + let rowIslandEl: IgcNgElement & InstanceType; + let hgrid: IgxHierarchicalGridComponent; + + const waitFor = async (condition: () => boolean) => { + for (let waited = 0; waited < 3000 && !condition(); waited += 20) { + await firstValueFrom(timer(20)); + } + }; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + + `; + hgridEl = testContainer.querySelector('#testHGrid'); + rowIslandEl = testContainer.querySelector('#developers'); + hgrid = (await hgridEl.ngElementStrategy[ComponentRefKey]).instance; + await rowIslandEl.ngElementStrategy[ComponentRefKey]; + hgrid.data = hgridData; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + const expandFirstRow = async () => { + hgrid.expandRow(hgridData[0].ProjectId); + await waitFor(() => hgrid.gridAPI.getChildGrids(false).length > 0); + return hgrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + }; + + it('should re-emit child grid events on the row island with the child grid as owner', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.columns.map(c => c.field)).toContain('DeveloperId'); + + // events without args are re-emitted with the owner only + const rangeSelected = firstValueFrom(fromEvent(rowIslandEl, 'rangeSelected')); + childGrid.rangeSelected.emit(); + expect((await rangeSelected).detail).toEqual({ owner: childGrid }); + + const sortingDone = firstValueFrom(fromEvent(rowIslandEl, 'sortingDone')); + childGrid.sort({ fieldName: 'DeveloperId', dir: SortingDirection.Desc, ignoreCase: false }); + const sortingArgs = (await sortingDone).detail; + expect(sortingArgs.owner).toBe(childGrid); + }); + + it('should emit selectedRowsChange for hierarchical grid', async () => { + const selectionChanged = firstValueFrom(fromEvent(hgridEl, 'selectedRowsChange')); + hgridEl.selectRows([hgridData[0].ProjectId]); + + expect((await selectionChanged).detail).toEqual([hgridData[0].ProjectId]); + }); + + it('should apply row island input changes to existing child grids', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.allowFiltering).toBe(false); + + rowIslandEl.allowFiltering = true; + await waitFor(() => childGrid.allowFiltering); + + expect(childGrid.allowFiltering).toBe(true); + }); + + it('should render the row island paginator template in child grids and keep it in the root paginator query', async () => { + rowIslandEl.paginatorTemplate = (() => html``) as any; + const childGrid = await expandFirstRow(); + + await waitFor(() => !!childGrid.paginator && hgrid.paginatorList?.length > 0); + + const paginatorEl = childGrid.nativeElement.querySelector('igc-paginator'); + expect(paginatorEl).toBeTruthy(); + const paginator = (await paginatorEl.ngElementStrategy[ComponentRefKey]).instance; + expect(childGrid.paginator).toBe(paginator); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((hgridEl.ngElementStrategy as any).cachedChildComponents.get('paginatorList')).toContain(paginator); + await waitFor(() => childGrid.dataView.length === 2); + expect(childGrid.dataView.length).toBe(2); + + // collapsing detaches the cached child grid from the DOM, the paginator stays registered and alive + hgrid.collapseRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(paginatorEl.isConnected).toBe(false); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((paginatorEl.ngElementStrategy as any).componentRef).toBeTruthy(); + + hgrid.expandRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(childGrid.paginator).toBe(paginator); + }); }); }); diff --git a/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts new file mode 100644 index 00000000000..a93a8c48a0b --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts @@ -0,0 +1,187 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgElementStrategyEvent } from '@angular/elements'; +import { firstValueFrom, timer } from 'rxjs'; +import { ComponentNgElementStrategy, ComponentNgElementStrategyFactory, extractProjectableNodes } from './ng-element-strategy'; +import { injector } from '../utils/injector-ref'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-strategy-test', + template: `
{{ value }}
`, + standalone: true +}) +class StrategyTestComponent { + @Input() + public value: string; + + @Input('aliasName') + public aliased: string; + + @Output() + public valueChanged = new EventEmitter(); + + public destroyed = false; + + public ngOnDestroy() { + this.destroyed = true; + } +} + +/** DESTROY_DELAY in ng-element-strategy */ +const DESTROY_DELAY = 10; + +describe('ComponentNgElementStrategy', () => { + let host: HTMLElement; + let strategy: ComponentNgElementStrategy; + + const componentRef = () => (strategy as any).componentRef; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + strategy = new ComponentNgElementStrategyFactory(StrategyTestComponent).create(injector); + }); + + afterEach(() => { + strategy.disconnect(); + host.remove(); + }); + + it('should create strategies that map input property names to template names', () => { + const factory = new ComponentNgElementStrategyFactory(StrategyTestComponent); + + expect(factory.create(injector)).toBeInstanceOf(ComponentNgElementStrategy); + expect(factory.inputMap.get('value')).toBe('value'); + expect(factory.inputMap.get('aliased')).toBe('aliasName'); + }); + + it('should cache input values set before connect and apply them on connect', () => { + strategy.setInputValue('value', 'initial'); + strategy.setInputValue('aliased', 'aliased initial'); + + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBe('initial'); + + strategy.connect(host); + + const instance = componentRef().instance as StrategyTestComponent; + expect(instance.value).toBe('initial'); + expect(instance.aliased).toBe('aliased initial'); + expect(host.querySelector('.value').textContent).toBe('initial'); + expect((strategy as any).initialInputValues.size).toBe(0); + }); + + it('should set inputs on the component through their template name after connect', () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + expect(strategy.getInputValue('aliased')).toBe('new value'); + + // setting an unchanged value should leave the view clean + componentRef().hostView.detectChanges(); + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + }); + + it('should emit component outputs through the events stream', () => { + const events: NgElementStrategyEvent[] = []; + const sub = strategy.events.subscribe(e => events.push(e)); + strategy.connect(host); + + (componentRef().instance as StrategyTestComponent).valueChanged.emit('changed'); + expect(events).toEqual([{ name: 'valueChanged', value: 'changed' }]); + + sub.unsubscribe(); + (componentRef().instance as StrategyTestComponent).valueChanged.emit('ignored'); + expect(events.length).toBe(1); + }); + + it('should project child nodes into the matching content slots', () => { + const header = document.createElement('header'); + header.textContent = 'Header'; + const span = document.createElement('span'); + span.textContent = 'Content'; + host.appendChild(span); + host.appendChild(header); + host.appendChild(document.createTextNode('Text')); + + strategy.connect(host); + + expect(host.querySelector('.header').contains(header)).toBe(true); + expect(host.querySelector('.rest').contains(span)).toBe(true); + expect(host.querySelector('.rest').textContent).toBe('ContentText'); + }); + + it('should destroy the component with a delay after disconnect', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + // second disconnect while already scheduled is a no-op + strategy.disconnect(); + expect(instance.destroyed).toBe(false); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(true); + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBeUndefined(); + }); + + it('should keep the component when reconnected before the scheduled destroy', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + strategy.connect(host); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(false); + expect(componentRef().instance).toBe(instance); + }); + + it('should not schedule destroy when disconnected before connect', async () => { + strategy.disconnect(); + expect((strategy as any).scheduledDestroyFn).toBeNull(); + }); +}); + +describe('extractProjectableNodes', () => { + const createHost = (...children: Node[]) => { + const host = document.createElement('div'); + children.forEach(c => host.appendChild(c)); + return host; + }; + + it('should distribute nodes by selector and default the rest to the wildcard slot', () => { + const header = document.createElement('header'); + const footer = document.createElement('div'); + footer.className = 'footer'; + const other = document.createElement('span'); + const text = document.createTextNode('text'); + const host = createHost(header, other, footer, text); + + const nodes = extractProjectableNodes(host, ['header', '*', '.footer']); + + expect(nodes).toEqual([[header], [other, text], [footer]]); + }); + + it('should drop nodes that match no selector when there is no wildcard slot', () => { + const header = document.createElement('header'); + const other = document.createElement('span'); + const host = createHost(other, document.createTextNode('text'), header); + + const nodes = extractProjectableNodes(host, ['header']); + + expect(nodes).toEqual([[header]]); + }); + + it('should return no slots for components without content projection', () => { + const host = createHost(document.createElement('span')); + + expect(extractProjectableNodes(host, [])).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts index 495a61669d7..5cd4a442289 100644 --- a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts +++ b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { html } from 'lit'; import { AsyncDirective, directive } from 'lit/async-directive.js'; import { TemplateWrapperComponent } from './wrapper.component'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { TemplateRefWrapper } from './template-ref-wrapper'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; const directiveLog = []; class ToLowerAsyncDirective extends AsyncDirective { @@ -64,4 +65,88 @@ describe('WrapperComponent', () => { embeddedView.destroy(); expect(directiveLog).toEqual(['render', 'disconnected']); }); + + it('should expose the context through a proxy with implicit and context props', () => { + let templateContext: any; + const context = { $implicit: 'implicit value', text: 'text value' }; + const templateRef = component.addTemplate((ctx) => { + templateContext = ctx; + return html`${ctx.implicit}`; + }); + const embeddedView = templateRef.createEmbeddedView(context); + embeddedView.detectChanges(); + + expect(templateContext.implicit).toBe('implicit value'); + expect(templateContext.text).toBe('text value'); + expect(templateContext.context).toBe(templateContext); + expect('implicit' in templateContext).toBe(true); + expect('text' in templateContext).toBe(false); + // not bridged, so the bridge props are not handled: + expect(templateContext.___contentId).toBeUndefined(); + expect(templateContext.___immediate).toBeUndefined(); + expect(embeddedView.rootNodes[0].textContent).toBe('implicit value'); + }); + + it('should init and teardown bridged templates with their content context', () => { + let templateContext: any; + const templateFunc: any = (ctx) => { + templateContext = ctx; + return html`Bridged`; + }; + templateFunc.___isBridged = true; + templateFunc.___onTemplateInit = vi.fn(); + templateFunc.___onTemplateTeardown = vi.fn(); + + const templateRef = component.addTemplate(templateFunc); + const embeddedView = templateRef.createEmbeddedView({}); + const root = embeddedView.rootNodes[0]; + + expect(templateFunc.___onTemplateInit).toHaveBeenCalledTimes(1); + const [initFunc, initRoot, contentContext] = templateFunc.___onTemplateInit.mock.calls[0]; + expect(initFunc).toBe(templateFunc); + expect(initRoot).toBe(root); + expect(contentContext.root).toBe(root); + expect(contentContext.templateFunction).toBe(templateFunc); + expect(contentContext._id).toBeTruthy(); + expect(root._id).toBe(contentContext._id); + expect((templateRef as any)._contentContext.get(contentContext._id)).toBe(contentContext); + + embeddedView.detectChanges(); + expect(templateContext.___contentId).toBe(contentContext._id); + expect(templateContext.___immediate).toBe(true); + expect(templateContext.___root).toBe(root); + + embeddedView.destroy(); + expect(templateFunc.___onTemplateTeardown).toHaveBeenCalledExactlyOnceWith(templateFunc, root, contentContext); + expect((templateRef as any)._contentContext.size).toBe(0); + }); + + it('should expose the inner template elementRef and ssrId', () => { + const templateRef = component.addTemplate(() => html``) as TemplateRefWrapper; + + expect(templateRef.elementRef).toBe(templateRef.innerTemplateRef.elementRef); + expect(templateRef.ssrId).toBe((templateRef.innerTemplateRef as any).ssrId); + }); + + it('should resolve the template function from a wrapped template ref', () => { + const first = () => html`1`; + const second = () => html`2`; + const firstRef = component.addTemplate(first) as TemplateRefWrapper; + const secondRef = component.addTemplate(second) as TemplateRefWrapper; + + expect(component.getTemplateFunction(firstRef)).toBe(first); + expect(component.getTemplateFunction(secondRef)).toBe(second); + expect(component.getTemplateFunction(null)).toBeUndefined(); + }); + + it('should emit templateRendered with the template container', () => { + const rendered: HTMLElement[] = []; + component.templateRendered.subscribe(container => rendered.push(container)); + const templateRef = component.addTemplate(() => html`Rendered`); + const embeddedView = templateRef.createEmbeddedView({}); + embeddedView.detectChanges(); + + expect(rendered).toEqual([embeddedView.rootNodes[0]]); + expect(rendered[0].textContent).toBe('Rendered'); + }); }); diff --git a/projects/igniteui-angular-elements/src/lib/exporters.spec.ts b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts new file mode 100644 index 00000000000..3be52ad3579 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts @@ -0,0 +1,63 @@ +import { IgxCsvExporterOptions, IgxCsvExporterService, IgxExcelExporterOptions, IgxExcelExporterService, CsvFileTypes } from 'igniteui-angular'; +import { IgcCsvExporterService } from './csv-exporter'; +import { IgcExcelExporterService } from './excel-exporter'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('Elements exporter services', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const gridInstance = { id: 'grid-instance' }; + const gridElement = { ngElementStrategy: { componentRef: { instance: gridInstance } } }; + + describe('IgcCsvExporterService', () => { + const options = new IgxCsvExporterOptions('Test', CsvFileTypes.CSV); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcCsvExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcCsvExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); + + describe('IgcExcelExporterService', () => { + const options = new IgxExcelExporterOptions('Test'); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcExcelExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcExcelExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); +}); 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 ac9573567d2..fc984090e92 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 @@ -3,7 +3,7 @@ import { ActionType, BroadcastIconsChangeMessage, IgxIconBroadcastService, SvgIc import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { IconMeta, IgxIconService } from 'igniteui-angular'; import { wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('Icon broadcast service', () => { let fixture: ComponentFixture; @@ -102,7 +102,53 @@ describe('Icon broadcast service', () => { expect(serviceRef.family).toBe("customCollection"); expect(serviceRef.name).toBe("customIcon"); }); - }) + + it('should ignore messages without collections or references.', async () => { + const iconService = fixture.componentInstance.iconService; + const addSvgSpy = vi.spyOn(iconService, 'addSvgIconFromText'); + const setRefSpy = vi.spyOn(iconService, 'setIconRef'); + + broadcastChannel.postMessage({ actionType: ActionType.SyncState }); + await wait(50); + + expect(addSvgSpy).not.toHaveBeenCalled(); + expect(setRefSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Page lifecycle', () => { + it('should close the channel on pagehide and re-create it on pageshow.', async () => { + const iconService = fixture.componentInstance.iconService; + await wait(50); + expect(events.length).toBe(1); + + globalThis.dispatchEvent(new Event('pagehide')); + const icon: Map = new Map([["hiddenIcon", { svg: buildIcon }]]); + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["hiddenCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "hiddenCollection")).toBeUndefined(); + + // hiding again with an already closed channel is a no-op + expect(() => globalThis.dispatchEvent(new Event('pagehide'))).not.toThrow(); + + globalThis.dispatchEvent(new Event('pageshow')); + await wait(50); + // re-created channel requests state sync again + // (services from previous tests listen to the global page events as well) + expect(events.length).toBeGreaterThan(1); + expect(events.every(e => e.actionType === ActionType.SyncState)).toBe(true); + + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["shownCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "shownCollection")).not.toBeUndefined(); + }); + }); }); @Component({ diff --git a/projects/igniteui-angular-elements/src/lib/state.component.spec.ts b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts new file mode 100644 index 00000000000..c1adcc7cfb9 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts @@ -0,0 +1,148 @@ +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; +import { firstValueFrom, timer } from 'rxjs'; +import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; +import { ComponentRefKey, IgcNgElement } from '../app/custom-strategy'; +import { IgcColumnComponent, IgcGridComponent, IgcGridStateComponent } from '../app/components'; +import { defineComponents } from '../utils/register'; +import { IgxGridComponent } from './grids/grid.component'; +import { IGridStateInfo, IgxGridStateComponent } from './state.component'; +import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; + +describe('Elements: Grid state component', () => { + let testContainer: HTMLDivElement; + let gridEl: IgcNgElement & InstanceType; + let stateEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + let state: IgxGridStateComponent; + + const sortingState = () => [{ fieldName: 'ProductID', dir: SortingDirection.Desc, ignoreCase: false }]; + + beforeAll(() => { + defineComponents(IgcGridComponent, IgcColumnComponent, IgcGridStateComponent); + }); + + beforeEach(async () => { + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + stateEl = testContainer.querySelector('igc-grid-state') as any; + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + state = (await stateEl.ngElementStrategy[ComponentRefKey]).instance; + gridEl.data = SampleTestData.foodProductData(); + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + afterEach(() => { + document.body.removeChild(testContainer); + }); + + it('should be bound to its parent grid', () => { + expect(state.grid).toBe(grid); + }); + + it('should get the state of all features by default', () => { + grid.sort(sortingState()); + + const gridState = stateEl.getState(); + + expect(gridState.sorting).toMatchObject(sortingState()); + expect(gridState.columns.map(c => c.field)).toEqual(['ProductID', 'ProductName']); + expect(gridState.filtering).toBeDefined(); + expect(gridState.rowSelection).toBeDefined(); + }); + + it('should get the state of only the requested features', () => { + grid.sort(sortingState()); + + expect(Object.keys(stateEl.getState('sorting'))).toEqual(['sorting']); + expect(Object.keys(stateEl.getState(['sorting', 'columns'])).sort()).toEqual(['columns', 'sorting']); + + const serialized = stateEl.getStateAsString(['sorting']); + expect(JSON.parse(serialized)).toEqual({ sorting: sortingState() }); + expect(Object.keys(JSON.parse(stateEl.getStateAsString())).length).toBeGreaterThan(1); + }); + + it('should apply state of all or only of the requested features', () => { + const gridState: IGridStateInfo = { + sorting: sortingState(), + moving: false + }; + + stateEl.applyState(gridState, ['moving']); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(false); + + stateEl.applyState(gridState); + expect(grid.sortingExpressions).toMatchObject(sortingState()); + expect(grid.dataView[0].ProductID).toBe(Math.max(...gridEl.data.map(x => x.ProductID))); + }); + + it('should emit stateParsed when applying state from string and apply modifications to the parsed state', async () => { + const parsedStates: IGridStateInfo[] = []; + stateEl.addEventListener('stateParsed', (e: CustomEvent) => { + parsedStates.push(structuredClone(e.detail)); + e.detail.sorting[0].dir = SortingDirection.Asc; + }); + + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState() })); + + expect(parsedStates).toEqual([{ sorting: sortingState() }]); + expect(grid.sortingExpressions[0].dir).toBe(SortingDirection.Asc); + + grid.clearSort(); + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState(), moving: true }), 'moving'); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(true); + }); + + it('should serialize Set search values of filtering expressions as arrays', () => { + const condition = IgxStringFilteringOperand.instance().condition('in'); + grid.filter('ProductName', new Set(['Chai', 'Chang']), condition); + + const serialized = JSON.parse(stateEl.getStateAsString(['filtering'])); + const expression = serialized.filtering.filteringOperands[0].filteringOperands[0]; + + expect(expression.searchVal).toEqual(['Chai', 'Chang']); + }); + + it('should omit externalObject values when serializing', () => { + const callback = (key: string, value: any) => (state as any).stringifyCallback(key, value); + const set = new Set([1, 2]); + + expect(callback('externalObject', { some: 'value' })).toBeUndefined(); + expect(callback('searchVal', set)).toEqual([1, 2]); + expect(callback('searchVal', 'text')).toBe('text'); + expect(callback('other', set)).toBe(set); + }); + + it('should apply the state through the element API from a grid in another container', async () => { + const otherContainer = document.createElement('div'); + document.body.appendChild(otherContainer); + try { + otherContainer.innerHTML = ` + + + + `; + const otherGridEl = otherContainer.querySelector('igc-grid'); + const otherStateEl = otherContainer.querySelector>('igc-grid-state'); + const otherGrid = (await otherGridEl.ngElementStrategy[ComponentRefKey]).instance as IgxGridComponent; + await otherStateEl.ngElementStrategy[ComponentRefKey]; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + grid.sort(sortingState()); + otherStateEl.applyStateFromString(stateEl.getStateAsString(['sorting'])); + + expect(otherGrid.sortingExpressions).toMatchObject(sortingState()); + } finally { + otherContainer.remove(); + } + }); +}); diff --git a/projects/igniteui-angular-elements/src/utils/register.spec.ts b/projects/igniteui-angular-elements/src/utils/register.spec.ts new file mode 100644 index 00000000000..8c0c62f3c56 --- /dev/null +++ b/projects/igniteui-angular-elements/src/utils/register.spec.ts @@ -0,0 +1,82 @@ +import { defineComponents, IgniteComponent, registerComponent } from './register'; +import { defineAllComponents } from './define-all'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +let uniqueId = 0; +const createComponent = (): IgniteComponent => { + const ctor = class extends HTMLElement { } as unknown as IgniteComponent; + ctor.tagName = `igc-register-test-${uniqueId++}`; + ctor.register = vi.fn(() => registerComponent(ctor)); + return ctor; +}; + +describe('Elements register utils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerComponent should define the component and register its dependencies first', () => { + const dependency = createComponent(); + const component = createComponent(); + const defineSpy = vi.spyOn(customElements, 'define'); + + registerComponent(component, dependency); + + expect(dependency.register).toHaveBeenCalledTimes(1); + expect(customElements.get(dependency.tagName)).toBe(dependency); + expect(customElements.get(component.tagName)).toBe(component); + expect(defineSpy.mock.calls.map(([tag]) => tag)).toEqual([dependency.tagName, component.tagName]); + }); + + it('registerComponent should not re-define an already defined component', () => { + const component = createComponent(); + registerComponent(component); + const defineSpy = vi.spyOn(customElements, 'define'); + + expect(() => registerComponent(component)).not.toThrow(); + expect(defineSpy).not.toHaveBeenCalled(); + }); + + it('defineComponents should call register on every component', () => { + const components = [createComponent(), createComponent()]; + + defineComponents(...components); + + components.forEach(c => { + expect(c.register).toHaveBeenCalledTimes(1); + expect(customElements.get(c.tagName)).toBe(c); + }); + }); + + it('defineAllComponents should define all grid related elements', () => { + defineAllComponents(); + + const tags = [ + 'igc-grid', + 'igc-tree-grid', + 'igc-hierarchical-grid', + 'igc-pivot-grid', + 'igc-pivot-data-selector', + 'igc-row-island', + 'igc-column-group', + 'igc-column-layout', + 'igc-column', + 'igc-paginator', + 'igc-grid-toolbar', + 'igc-action-strip', + 'igc-grid-state', + 'igc-grid-editing-actions', + 'igc-grid-pinning-actions', + 'igc-grid-toolbar-title', + 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-hiding', + 'igc-grid-toolbar-pinning', + 'igc-grid-toolbar-exporter', + 'igc-grid-toolbar-advanced-filtering', + 'igc-query-builder', + 'igc-query-builder-header' + ]; + const undefinedTags = tags.filter(tag => !customElements.get(tag)); + expect(undefinedTags).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts index 5f5f396f5f7..cb2123bb691 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts @@ -1,7 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ElementRef } from '@angular/core'; import { IgxChartMenuComponent } from './chart-dialog.component'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartMenuComponent', () => { let component: IgxChartMenuComponent; @@ -234,3 +234,110 @@ describe('IgxChartMenuComponent', () => { }); }); }); + +describe('IgxChartMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxChartMenuComponent; + let chartDirective: { chartFactory: ReturnType }; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + const headerButtons = () => queryAll('.action-buttons-wrapper button'); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxChartMenuComponent] }); + fixture = TestBed.createComponent(IgxChartMenuComponent); + component = fixture.componentInstance; + chartDirective = { chartFactory: vi.fn() }; + component.chartDirective = chartDirective; + component.allCharts = ['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']; + component.width = 1000; + component.height = 500; + fixture.detectChanges(); + component.createChart('ColumnStacked'); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create the chart in the chart area and show its title in the header', () => { + expect(chartDirective.chartFactory).toHaveBeenCalledWith('ColumnStacked', component.chartArea); + expect(query('.header-text').textContent!.trim()).toBe('Column Stacked Chart'); + }); + + it('should bind the host size to 70% of the given dimensions and to 100% in full screen', () => { + const host: HTMLElement = fixture.nativeElement; + expect(host.style.width).toBe('700px'); + expect(host.style.height).toBe('350px'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(true); + expect(host.style.width).toBe('1000px'); + expect(host.style.height).toBe('500px'); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen_exit'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(false); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen'); + }); + + it('should emit closed when the close button is clicked', () => { + const closedSpy = vi.fn(); + component.closed.subscribe(closedSpy); + + headerButtons()[1].click(); + + expect(closedSpy).toHaveBeenCalledTimes(1); + }); + + it('should toggle the chart config area with the chevron button', () => { + const configArea = query('.chart-config-area'); + const toggleButton = query('.selection-area > div > button'); + expect(configArea.classList).toContain('opened'); + expect(query('.chart-area').classList).not.toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_right'); + + toggleButton.click(); + fixture.detectChanges(); + + expect(component.isConfigAreaExpanded).toBe(true); + expect(configArea.classList).toContain('closed'); + expect(configArea.classList).not.toContain('opened'); + expect(query('.chart-area').classList).toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_left'); + }); + + it('should render a section only for the main chart types that have available charts', () => { + const sections = queryAll('.wrapper') + .filter(w => w.querySelector('.types-section')) + .map(w => w.firstElementChild!.textContent!.trim()); + + expect(sections).toEqual(['Column Chart', 'Bar Chart', 'Pie Chart']); + }); + + it('should render a button per available chart and mark the current one as selected', () => { + const buttons = queryAll('.types-section-btn'); + + expect(buttons.map(b => b.title)).toEqual(['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']); + expect(buttons[1].classList).toContain('selected'); + expect(buttons[0].classList).not.toContain('selected'); + }); + + it('should create the chosen chart when a chart type button is clicked', () => { + const pieButton = queryAll('.types-section-btn').find(b => b.title === 'Pie')!; + + pieButton.click(); + fixture.detectChanges(); + + expect(chartDirective.chartFactory).toHaveBeenLastCalledWith('Pie', component.chartArea); + expect(component.currentChartType).toBe('Pie'); + expect(pieButton.classList).toContain('selected'); + expect(query('.header-text').textContent!.trim()).toBe('Pie Chart'); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts index ab82c899fbe..9c5662db7f9 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts @@ -1,6 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EventEmitter } from '@angular/core'; import { Subject } from 'rxjs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxOverlayService } from 'igniteui-angular/core'; import { IgxContextMenuComponent } from './context-menu.component'; import { CHART_TYPE } from '../directives/chart-integration/chart-types'; @@ -305,11 +306,104 @@ describe('IgxContextMenuComponent', () => { }); describe('overlayService.opening with chartDialogResizeNotify', () => { + let overlayElement: HTMLElement; + let resizeNotify: Subject; + + const createWrapper = (classes: string[], hidden = false) => { + const wrapper = document.createElement('div'); + wrapper.classList.add(...classes); + if (hidden) { + wrapper.style.visibility = 'hidden'; + } + const content = document.createElement('div'); + wrapper.appendChild(content); + overlayElement.appendChild(wrapper); + return content; + }; + + const openDialogWithResizeNotify = () => { + resizeNotify = new Subject(); + const mockInstance = { chartDialogResizeNotify: resizeNotify, closed: undefined } as any; + (mockOverlay.opening as Subject).next({ id: 'dialog-id', componentRef: { instance: mockInstance } }); + }; + + beforeEach(() => { + overlayElement = document.createElement('div'); + document.body.appendChild(overlayElement); + }); + + afterEach(() => { + overlayElement.remove(); + }); + it('should not throw when opening event has no componentRef', () => { expect(() => { (mockOverlay.opening as Subject).next({ componentRef: undefined }); }).not.toThrow(); }); + + it('should resize the content of the visible flex wrapper to the new dialog width', () => { + const hiddenContent = createWrapper(['igx-overlay__wrapper--flex'], true); + const visibleContent = createWrapper(['igx-overlay__wrapper--flex']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(mockOverlay.getOverlayById).toHaveBeenCalledWith('dialog-id'); + expect(visibleContent.style.width).toBe('420px'); + expect(hiddenContent.style.width).toBe(''); + }); + + it('should ignore wrappers that are not flex overlay wrappers', () => { + const content = createWrapper(['igx-overlay__wrapper']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(content.style.width).toBe(''); + }); + + it('should not throw when the flex wrapper has no content element', () => { + const wrapper = document.createElement('div'); + wrapper.classList.add('igx-overlay__wrapper--flex'); + overlayElement.appendChild(wrapper); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + + it('should not throw when the overlay has no element', () => { + mockOverlay.getOverlayById.mockReturnValue({ elementRef: undefined }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + }); + + describe('overlayService.closing subscription without an open dialog', () => { + it('should not reopen tabsMenu when the chart dialog closes and no dialog id is tracked', () => { + (mockOverlay.closing as Subject).next({ + componentRef: { instance: TestBed.createComponent(IgxChartMenuComponent).componentInstance } + }); + + expect(mockTabsMenu.open).not.toHaveBeenCalled(); + }); + }); + + describe('without optional chart and formatting directives', () => { + it('should initialize from the context directive without subscribing to missing directives', () => { + mockContextDirective.chartsDirective = null; + mockContextDirective.textFormatter = null; + mockContextDirective.charts = []; + mockContextDirective.formatters = []; + + expect(() => component.ngAfterViewInit()).not.toThrow(); + expect(component.chartTypes).toEqual([]); + expect(component.textFormatters).toEqual([]); + }); }); describe('constructor', () => { @@ -332,3 +426,231 @@ describe('IgxContextMenuComponent', () => { }); }); }); + +describe('IgxContextMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxContextMenuComponent; + let overlay: IgxOverlayService; + let contextDirective: any; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + + const createComponent = async (overrides: any = {}) => { + contextDirective = { + charts: [CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped, CHART_TYPE.Pie], + formatters: ['Top 10', 'Duplicate Values'], + displayCreationTab: true, + chartsDirective: { + chartTypesDetermined: new EventEmitter(), + chartFactory: vi.fn() + }, + textFormatter: { + formattersReady: new EventEmitter(), + formatCells: vi.fn(), + clearFormatting: vi.fn(), + formatter: undefined + }, + buttonClose: new EventEmitter(), + gridResizeNotify: new Subject(), + grid: { nativeElement: document.createElement('div') }, + ...overrides + }; + fixture = TestBed.createComponent(IgxContextMenuComponent); + component = fixture.componentInstance; + component.contextDirective = contextDirective; + // ngAfterViewInit copies these from the context directive after the view is checked; + // seed the same values up front so the first check is stable in dev mode + component.chartTypes = contextDirective.charts; + component.textFormatters = contextDirective.formatters; + component.displayCreationTab = contextDirective.displayCreationTab; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + const selectChartsTab = async () => { + component.tabs.selectedIndex = 1; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxContextMenuComponent, NoopAnimationsModule] }); + overlay = TestBed.inject(IgxOverlayService); + }); + + afterEach(() => { + component?.closeDialog(); + TestBed.resetTestingModule(); + }); + + it('should toggle the tabs menu when the analytics button is clicked', async () => { + await createComponent(); + expect(component.tabsMenu.collapsed).toBe(true); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(false); + expect(component.currentChartType).toBe(CHART_TYPE.ColumnGrouped); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should close an open tabs menu when the grid is resized', async () => { + await createComponent(); + component.toggleTabMenu(); + expect(component.tabsMenu.collapsed).toBe(false); + + contextDirective.gridResizeNotify.next(); + + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should not close the tabs menu on grid resize when it is already closed', async () => { + await createComponent(); + const closeSpy = vi.spyOn(component.tabsMenu, 'close'); + + contextDirective.gridResizeNotify.next(); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should render a formatting option per formatter and apply it on click', async () => { + await createComponent(); + const conditions = queryAll('.condition'); + + expect(conditions.length).toBe(2); + expect(conditions[0].querySelector('.name')!.textContent!.trim()).toBe('Top 10%'); + expect(conditions[1].querySelector('.name')!.textContent!.trim()).toBe('Duplicate Values'); + + conditions[1].click(); + fixture.detectChanges(); + + expect(contextDirective.textFormatter.formatCells).toHaveBeenCalledWith('Duplicate Values'); + expect(conditions[1].classList).toContain('selected--condition'); + expect(conditions[0].classList).not.toContain('selected--condition'); + }); + + it('should disable "Clear All" until a formatter is applied and clear formatting on click', async () => { + await createComponent(); + const clear = query('.clear'); + expect(clear.classList).toContain('disableButton'); + + contextDirective.textFormatter.formatter = 'Top 10'; + fixture.detectChanges(); + expect(clear.classList).not.toContain('disableButton'); + + component.currentFormatter = 'Top 10'; + clear.click(); + + expect(contextDirective.textFormatter.clearFormatting).toHaveBeenCalled(); + expect(component.currentFormatter).toBeUndefined(); + }); + + it('should disable "Clear All" when there is no conditional formatting directive', async () => { + await createComponent({ textFormatter: null }); + + expect(query('.clear').classList).toContain('disableButton'); + }); + + it('should render at most four chart options and mark the current chart as selected', async () => { + await createComponent(); + component.currentChartType = CHART_TYPE.BarGrouped; + await selectChartsTab(); + + const charts = queryAll('.action.chart'); + expect(charts.length).toBe(4); + expect(charts.map(c => c.querySelector('.name')!.textContent!.trim())) + .toEqual([CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped]); + expect(charts[1].classList).toContain('selected'); + expect(charts[0].classList).not.toContain('selected'); + expect(query('.more-action').classList).not.toContain('disableButton'); + }); + + it('should preview a chart on mouseenter and hide the preview on mouseleave', async () => { + await createComponent(); + component.toggleTabMenu(); + await selectChartsTab(); + const chart = queryAll('.action.chart')[2]; + + chart.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + + expect(component.currentChartType).toBe(CHART_TYPE.LineGrouped); + expect(contextDirective.chartsDirective.chartFactory).toHaveBeenCalledWith(CHART_TYPE.LineGrouped, component.chartPreview); + expect(component.chartPreviewDialog.collapsed).toBe(false); + + chart.dispatchEvent(new MouseEvent('mouseleave')); + fixture.detectChanges(); + + expect(component.chartPreviewDialog.collapsed).toBe(true); + }); + + it('should open the chart dialog configured from the context when a chart option is clicked', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + component.toggleTabMenu(); + await selectChartsTab(); + + queryAll('.action.chart')[1].click(); + fixture.detectChanges(); + + // the first opening is the tabs menu toggle, which has no componentRef + const dialogArgs = openingSpy.mock.calls.map(c => c[0]).find(args => args.componentRef); + const dialog = dialogArgs.componentRef.instance as IgxChartMenuComponent; + expect(dialog).toBeInstanceOf(IgxChartMenuComponent); + expect(dialog.currentChartType).toBe(CHART_TYPE.BarGrouped); + expect(dialog.allCharts).toBe(component.chartTypes); + expect(dialog.chartDirective).toBe(contextDirective.chartsDirective); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should open the dialog with the current chart type from "More..." and close it via the dialog close event', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + await selectChartsTab(); + component.currentChartType = CHART_TYPE.Pie; + + query('.more-action').click(); + fixture.detectChanges(); + + const dialogInfo = openingSpy.mock.calls[0][0]; + const dialog = dialogInfo.componentRef.instance as IgxChartMenuComponent; + expect(dialog.currentChartType).toBe(CHART_TYPE.Pie); + expect(overlay.getOverlayById(dialogInfo.id)).toBeDefined(); + + dialog.closed.emit(); + + expect(overlay.getOverlayById(dialogInfo.id)).toBeFalsy(); + }); + + it('should disable the chart creation tab when there are no charts', async () => { + await createComponent({ charts: [] }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(2); + expect(headers[0].getAttribute('aria-disabled')).toBe('false'); + expect(headers[1].getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not render the chart creation tab when displayCreationTab is false', async () => { + await createComponent({ displayCreationTab: false }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(1); + expect(headers[0].textContent!.trim()).toBe('Conditional Formatting'); + }); + + it('should select the chart creation tab when there are charts but no formatters', async () => { + await createComponent({ formatters: [] }); + + expect(component.tabs.selectedIndex).toBe(1); + expect(queryAll('.action.chart').length).toBe(4); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts index b13a0a2f872..886f12973fd 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts @@ -633,4 +633,120 @@ describe('IgxContextMenuDirective', () => { expect(mockOverlay.hide).toHaveBeenCalledWith('some-id'); }); }); + + describe('grid resize observer', () => { + it('should observe the grid element and forward resize notifications to gridResizeNotify', () => { + let observerCallback: () => void; + const observe = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { + observerCallback = cb; + } + public observe = observe; + public disconnect = vi.fn(); + }); + directive.ngOnDestroy(); + const fresh = TestBed.runInInjectionContext(() => new IgxContextMenuDirective()); + fresh.ngOnInit(); + fresh.ngAfterViewInit(); + const resizeSpy = vi.fn(); + fresh.gridResizeNotify.subscribe(resizeSpy); + + observerCallback!(); + + expect(observe).toHaveBeenCalledWith(mockGrid.nativeElement); + expect(resizeSpy).toHaveBeenCalledTimes(1); + fresh.ngOnDestroy(); + vi.unstubAllGlobals(); + }); + }); + + describe('column selection while the button is visible', () => { + const headerElement = document.createElement('div'); + + beforeEach(() => { + mockGrid.selectedColumns.mockReturnValue([ + { visibleIndex: 0, headerCell: { nativeElement: headerElement } } + ]); + (directive as any)._collapsed = false; + (directive as any)._id = 'some-id'; + }); + + it('should emit buttonClose without hiding when the selection change has no old selection', () => { + const closeSpy = vi.fn(); + directive.buttonClose.subscribe(closeSpy); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(mockOverlay.hide).not.toHaveBeenCalled(); + }); + + it('should reposition the existing overlay at the header cell and update its position strategy', () => { + const info = { settings: { positionStrategy: undefined } }; + mockOverlay.getOverlayById.mockReturnValue(info); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(mockOverlay.reposition).toHaveBeenCalledWith('some-id'); + expect(mockOverlay.show).not.toHaveBeenCalled(); + expect(info.settings.positionStrategy).toBe((directive as any)._analyticsBtnSettings.positionStrategy); + expect((directive as any)._analyticsBtnSettings.target).toBe(headerElement); + }); + }); + + describe('without optional chart and formatting directives', () => { + let bare: IgxContextMenuDirective; + + beforeEach(() => { + // detach the default directive so only the bare instance reacts to grid events + directive.ngOnDestroy(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IgxContextMenuDirective, + { provide: IgxGridComponent, useValue: mockGrid }, + { provide: IgxOverlayService, useValue: mockOverlay } + ] + }); + bare = TestBed.inject(IgxContextMenuDirective); + bare.ngOnInit(); + bare.ngAfterViewInit(); + }); + + afterEach(() => { + bare.ngOnDestroy(); + }); + + it('should have null textFormatter and chartsDirective', () => { + expect(bare.textFormatter).toBeNull(); + expect(bare.chartsDirective).toBeNull(); + expect(bare.formatters).toEqual([]); + expect(bare.charts).toEqual([]); + }); + + it('should still handle range selection and show the button', () => { + mockGrid.gridAPI.get_cell_by_index.mockReturnValue({ + row: { key: 0 }, + column: { index: 1 }, + nativeElement: document.createElement('div') + }); + + mockGrid.rangeSelected.next({ rowStart: 0, rowEnd: 1, columnStart: 0, columnEnd: 0 }); + vi.advanceTimersByTime(200); + + expect(mockGrid.getSelectedData).not.toHaveBeenCalled(); + expect(mockOverlay.show).toHaveBeenCalledWith('overlay-id'); + }); + + it('should still clear cell selection on column selection', () => { + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'], oldSelection: [] }); + vi.advanceTimersByTime(100); + + expect(mockGrid.clearCellSelection).toHaveBeenCalled(); + expect(mockGrid.getSelectedColumnsData).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts index c1dfc9dcd98..f4ecadfd707 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts @@ -1,6 +1,14 @@ +import { Component, ViewChild, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxColumnSeriesComponent, IgxDataChartCategoryModule, IgxDataChartComponent, + IgxDataChartCoreModule, IgxDataChartScatterModule, IgxDataChartStackedModule, IgxItemLegendComponent, + IgxItemLegendModule, IgxLegendComponent, IgxLegendModule, IgxNumericXAxisModule, IgxNumericYAxisModule, + IgxPieChartComponent, IgxPieChartModule, IgxStacked100BarSeriesComponent, IgxStackedColumnSeriesComponent +} from 'igniteui-angular-charts'; import { CHART_TYPE, OPTIONS_TYPE } from './chart-types'; import { IgxChartIntegrationDirective, IDeterminedChartTypesArgs } from './chart-integration.directive'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartIntegrationDirective', () => { let directive: IgxChartIntegrationDirective; @@ -442,4 +450,111 @@ describe('IgxChartIntegrationDirective', () => { expect(result).toBeDefined(); }); }); + + describe('chartFactory with a ViewContainerRef', () => { + @Component({ + template: ``, + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxDataChartScatterModule, IgxPieChartModule, IgxLegendModule, IgxItemLegendModule, + IgxNumericXAxisModule, IgxNumericYAxisModule, IgxCategoryXAxisModule, IgxCategoryYAxisModule + ] + }) + class ChartHostComponent { + @ViewChild('host', { read: ViewContainerRef, static: true }) + public host!: ViewContainerRef; + } + + let fixture: ComponentFixture; + let vcr: ViewContainerRef; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChartHostComponent] }); + fixture = TestBed.createComponent(ChartHostComponent); + fixture.detectChanges(); + vcr = fixture.componentInstance.host; + directive.chartData = [{ name: 'A', q1: 10, q2: 5 }, { name: 'B', q1: 20, q2: 15 }]; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create a pie chart with an item legend', () => { + let emitted: any; + directive.chartCreationDone.subscribe(c => emitted = c); + + const chart = directive.chartFactory(CHART_TYPE.Pie, vcr); + + expect(chart).toBeInstanceOf(IgxPieChartComponent); + expect(emitted).toBe(chart); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxItemLegendComponent); + expect(chart.valueMemberPath).toBe('q1'); + expect(chart.labelMemberPath).toBe('name'); + }); + + it('should create a data chart with one series per numeric column and a legend', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxLegendComponent); + expect(chart.series.count).toBe(2); + expect(chart.series.item(0)).toBeInstanceOf(IgxColumnSeriesComponent); + expect(chart.series.item(1).valueMemberPath).toBe('q2'); + expect(chart.axes.count).toBe(2); + }); + + it('should not create a legend when useLegend is false', () => { + directive.useLegend = false; + + const chart = directive.chartFactory(CHART_TYPE.LineGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(1); + expect(chart.legend).toBeFalsy(); + }); + + it('should create one stacked series with a fragment per numeric column', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('q1'); + expect(series.series.item(0).title).toBe('q1'); + expect(series.series.item(1).valueMemberPath).toBe('q2'); + }); + + it('should not apply the series model to stacked fragments', () => { + directive.setChartComponentOptions(CHART_TYPE.ColumnStacked, OPTIONS_TYPE.Series, { title: 'custom' }); + + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + const fragment = chart.series.item(0).series.item(0); + expect(fragment.title).toBe('q1'); + }); + + it('should use the label member path on the y axis for stacked bar charts', () => { + const chart = directive.chartFactory(CHART_TYPE.Bar100Stacked, vcr); + + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStacked100BarSeriesComponent); + expect(series.yAxis.label).toBe('name'); + expect(series.xAxis.label).toBeFalsy(); + }); + + it('should configure the bubble size scale when creating in a container', () => { + directive.chartData = [{ name: 'A', x: 1, y: 2, r: 3 }, { name: 'B', x: 4, y: 5, r: 6 }]; + + const chart = directive.chartFactory(CHART_TYPE.ScatterBubble, vcr); + + const series = chart.series.item(0); + expect(series.radiusMemberPath).toBe('y'); + expect(series.radiusScale.minimumValue).toBe(10); + expect(series.radiusScale.maximumValue).toBe(60); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts index 48681021e4e..2acb949af6a 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { IgxPieChartInitializer, IgxDataChartInitializer, @@ -13,9 +14,17 @@ import { IgxScatterLineSeriesComponent, IgxStackedColumnSeriesComponent, IgxStackedBarSeriesComponent, - IgxStacked100BarSeriesComponent + IgxStacked100BarSeriesComponent, + IgxDataChartComponent, + IgxDataChartCoreModule, + IgxDataChartCategoryModule, + IgxDataChartStackedModule, + IgxCategoryXAxisModule, + IgxCategoryYAxisModule, + IgxNumericXAxisModule, + IgxNumericYAxisModule } from 'igniteui-angular-charts'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Helper to create a mock chart with series and axes arrays function createMockDataChart() { @@ -220,4 +229,82 @@ describe('IgxStackedDataChartInitializer', () => { const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); expect(init.seriesType).toBe(IgxStackedBarSeriesComponent); }); + + describe('initChart', () => { + const stackedOptions = (): IChartComponentOptions => ({ + chartOptions: { width: '100%' }, + stackedFragmentOptions: [ + { valueMemberPath: 'val1', title: 'V1' }, + { valueMemberPath: 'val2', title: 'V2' } + ] as any, + xAxisOptions: { label: 'name' }, + yAxisOptions: { labelExtent: 40 } + }); + + let chart: IgxDataChartComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxNumericXAxisModule, IgxNumericYAxisModule + ] + }); + chart = TestBed.createComponent(IgxDataChartComponent).componentInstance; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should add a single stacked series with one fragment per fragment option', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('val1'); + expect(series.series.item(0).title).toBe('V1'); + expect(series.series.item(1).valueMemberPath).toBe('val2'); + }); + + it('should bind the series to the initializer axes and add them to the chart', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series.xAxis).toBe((init as any).xAxis); + expect(series.yAxis).toBe((init as any).yAxis); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + expect(chart.axes.item(1)).toBe((init as any).yAxis); + expect((init as any).xAxis.label).toBe('name'); + expect((init as any).yAxis.labelExtent).toBe(40); + }); + + it('should apply chart options and return the same chart instance', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + const result = init.initChart(chart, stackedOptions()); + + expect(result).toBe(chart); + expect(chart.width).toBe('100%'); + }); + + it('should replace existing series and axes when re-initializing a chart', () => { + new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent).initChart(chart, stackedOptions()); + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + expect(chart.series.item(0)).toBeInstanceOf(IgxStackedBarSeriesComponent); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts index 60d9009d267..c7775106c04 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts @@ -648,4 +648,78 @@ describe('IgxConditionalFormattingDirective', () => { expect(directive.formatColors.text).toBe('#FFF'); }); }); + + describe('duplicates and uniques with text values and out-of-range cells', () => { + beforeEach(() => { + (directive as any).formatedRange = new Map([[0, new Set([0])]]); + }); + + it('should return undefined color for duplicates when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 10 }]); + expect(directive.duplicates.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should return undefined color for uniques when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }]); + expect(directive.uniques.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should compare text values only against text data for duplicates', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.duplicates.backgroundColor(null, 0, 'A', 0)).toBe(directive.formatColors.info); + expect(directive.duplicates.color(null, 0, 'B', 0)).toBe(''); + }); + + it('should compare text values only against text data for uniques', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.uniques.backgroundColor(null, 0, 'B', 0)).toBe(directive.formatColors.info); + expect(directive.uniques.color(null, 0, 'A', 0)).toBe(''); + }); + }); + + describe('formatCells range handling', () => { + let col0: any, col1: any, col2: any; + + beforeEach(() => { + col0 = { visibleIndex: 0, cellStyles: undefined }; + col1 = { visibleIndex: 1, cellStyles: undefined }; + col2 = { visibleIndex: 2, cellStyles: undefined }; + mockGrid.visibleColumns = [col0, col1, col2]; + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 20 }]); + mockGrid.data = [{ val: 10 }, { val: 20 }]; + }); + + it('should use the provided formatRange instead of the grid selected ranges', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + const customRange: any = [{ rowStart: 1, rowEnd: 1, columnStart: 2, columnEnd: 2 }]; + + directive.formatCells(ConditionalFormattingType.ColorScale, customRange); + + expect(directive.isWithInFormattedRange(1, 2)).toBe(true); + expect(directive.isWithInFormattedRange(0, 0)).toBe(false); + }); + + it('should keep the existing formatted range when reset is false', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + directive.formatCells(ConditionalFormattingType.ColorScale); + + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 1, rowEnd: 1, columnStart: 1, columnEnd: 1 }]); + directive.formatCells(ConditionalFormattingType.Empty, undefined, false); + + expect(directive.isWithInFormattedRange(0, 0)).toBe(true); + expect(directive.isWithInFormattedRange(1, 1)).toBe(false); + expect(directive.formatter).toBe(ConditionalFormattingType.Empty); + }); + + it('should not notify the grid when no visible column falls into the range', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 1, columnStart: 5, columnEnd: 6 }]); + + directive.formatCells(ConditionalFormattingType.ColorScale); + + expect(mockGrid.notifyChanges).not.toHaveBeenCalled(); + expect(col0.cellStyles).toBeNull(); + expect(col1.cellStyles).toBeNull(); + expect(col2.cellStyles).toBeNull(); + }); + }); }); From 168c83e0b8267e9ac72893341dc424b219c29804 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 22:25:17 +0300 Subject: [PATCH 05/22] Update test to check toggle visibility on grid scroll Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../igniteui-angular-elements/src/app/custom-strategy.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 add696e8bc6..13ce4cde1d6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -726,6 +726,7 @@ describe('Elements: ', () => { gridEl.remove(); await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + testContainer.appendChild(toggle); gridEl.dispatchEvent(new CustomEvent('gridScroll')); expect(toggle.hide).not.toHaveBeenCalled(); From 981b1289b7979675a0f10660de561c2cb0f628ac Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:16:59 +0300 Subject: [PATCH 06/22] fix(tree-grid): skip autogenerate when data is set before init Setting data on a tree grid with autoGenerate before ngOnInit threw, since the grid API service has no grid yet. Columns are generated on init instead, matching the grid and hierarchical grid. Co-Authored-By: Claude Opus 5.5 --- .../grids/tree-grid/src/tree-grid.component.spec.ts | 11 +++++++++++ .../grids/tree-grid/src/tree-grid.component.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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(); From 2e6e798b353f4f8f8d42b5b2e37080bca0e7c866 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:17:00 +0300 Subject: [PATCH 07/22] fix(elements): apply writable props, filter factory and icon actions - Set writable additional properties (e.g. igc-column selected) on the instance, since they are not inputs and setInput ignored them - Add the deprecated getFilterFactory to igc-grid, which extends the base grid component instead of being it - Only process the icon broadcast message part for its action type - Return proxies, not result wrappers, for component arrays in events Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ .../src/app/create-custom-element.spec.ts | 27 +++++++++++- .../src/app/create-custom-element.ts | 3 +- .../src/app/custom-strategy.spec.ts | 44 ++++++++++++++++--- .../src/app/custom-strategy.ts | 11 ++++- .../src/lib/icon.broadcast.service.spec.ts | 21 +++++++++ .../src/lib/icon.broadcast.service.ts | 4 +- 7 files changed, 104 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8297582c72..1b0dd0ef6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,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. ## 22.2.3 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..0568c3db84c 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 { 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'; @@ -590,12 +590,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 +686,22 @@ 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; @@ -784,6 +800,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; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index dd27277d4e3..7e09a7b2e69 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -296,6 +296,13 @@ 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; + componentRef.changeDetectorRef.markForCheck(); + this.notifyChanges(); + return; + } super.setInputValue(property, value); } @@ -508,8 +515,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!); } } From ecdbf95fdaffd7dd67197318ff443402d37788c8 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:03:16 +0300 Subject: [PATCH 08/22] fix(elements): addressing copilot comment --- .../src/app/custom-strategy.spec.ts | 15 +++++++++++++++ .../src/app/custom-strategy.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) 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 0568c3db84c..866cfd7d3d0 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -702,6 +702,21 @@ describe('Elements: ', () => { 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index 7e09a7b2e69..fca79b46727 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -306,6 +306,24 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { 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); + } + } + public override getInputValue(property: string): any { let returnValue = super.getInputValue(property); From acff26a0d525927b2dc2d76077d4e9c9a5d9d64e Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:11:36 +0300 Subject: [PATCH 09/22] test(elements): fixing the unit test by isolating in config --- angular.json | 1 + .../src/app/custom-strategy-deferred.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index e53bfb73fa3..d00812f770b 100644 --- a/angular.json +++ b/angular.json @@ -455,6 +455,7 @@ "buildTarget": "igniteui-angular-elements:build:development,test", "tsConfig": "projects/igniteui-angular-elements/tsconfig.spec.json", "browsers": ["chromium"], + "isolate": true, "coverageInclude": [ "projects/igniteui-angular-elements/src/**/*.ts", "projects/igniteui-angular-elements/src/**/*.html" diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts index a008cb8ff8a..ccf04ad8217 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -7,7 +7,8 @@ import { describe, it, expect } from 'vitest'; describe('Elements: deferred parent definition', () => { it('should wait for the parent element to be defined before attaching to it', async () => { - // Relies on the per-file isolation of the test runner for a clean custom elements registry + // Relies on per-file isolation (`isolate: true` in angular.json) for a clean custom elements registry, + // otherwise other spec files sharing the same browser page may have already defined `igc-tree-grid` expect(customElements.get('igc-tree-grid')).toBeUndefined(); defineComponents(IgcColumnComponent); From 03d22f523640aaab19ea52a8cb7591da95178edb Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:16:59 +0300 Subject: [PATCH 10/22] fix(tree-grid): skip autogenerate when data is set before init Setting data on a tree grid with autoGenerate before ngOnInit threw, since the grid API service has no grid yet. Columns are generated on init instead, matching the grid and hierarchical grid. Co-Authored-By: Claude Opus 5.5 --- .../grids/tree-grid/src/tree-grid.component.spec.ts | 11 +++++++++++ .../grids/tree-grid/src/tree-grid.component.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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(); From c7a215ab122c57dba14b7687fdd3222a874a8a78 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:17:00 +0300 Subject: [PATCH 11/22] fix(elements): apply writable props, filter factory and icon actions - Set writable additional properties (e.g. igc-column selected) on the instance, since they are not inputs and setInput ignored them - Add the deprecated getFilterFactory to igc-grid, which extends the base grid component instead of being it - Only process the icon broadcast message part for its action type - Return proxies, not result wrappers, for component arrays in events Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ .../src/app/create-custom-element.spec.ts | 27 +++++++++++- .../src/app/create-custom-element.ts | 3 +- .../src/app/custom-strategy.spec.ts | 44 ++++++++++++++++--- .../src/app/custom-strategy.ts | 11 ++++- .../src/lib/icon.broadcast.service.spec.ts | 21 +++++++++ .../src/lib/icon.broadcast.service.ts | 4 +- 7 files changed, 104 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8297582c72..1b0dd0ef6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,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. ## 22.2.3 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..0568c3db84c 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 { 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'; @@ -590,12 +590,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 +686,22 @@ 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; @@ -784,6 +800,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; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index dd27277d4e3..7e09a7b2e69 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -296,6 +296,13 @@ 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; + componentRef.changeDetectorRef.markForCheck(); + this.notifyChanges(); + return; + } super.setInputValue(property, value); } @@ -508,8 +515,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!); } } From 5939ea3a250fa8d7f68c2136883ed5f86d03ad5d Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:03:16 +0300 Subject: [PATCH 12/22] fix(elements): addressing copilot comment --- .../src/app/custom-strategy.spec.ts | 15 +++++++++++++++ .../src/app/custom-strategy.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) 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 0568c3db84c..866cfd7d3d0 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -702,6 +702,21 @@ describe('Elements: ', () => { 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index 7e09a7b2e69..fca79b46727 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -306,6 +306,24 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { 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); + } + } + public override getInputValue(property: string): any { let returnValue = super.getInputValue(property); From d49d6ed69b17c58c57db697b01dc0c84f13ff8fc Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:50:38 +0300 Subject: [PATCH 13/22] fix(*): applying review comments --- .../src/app/custom-strategy.spec.ts | 61 ++++++++++++++++++- .../src/app/custom-strategy.ts | 31 +++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 866cfd7d3d0..3be2cf7b9f5 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,5 +1,5 @@ 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 { filter, firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; @@ -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, @@ -717,6 +719,42 @@ describe('Elements: ', () => { 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 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; @@ -851,13 +889,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 fca79b46727..9d96b115cd1 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -33,6 +33,11 @@ 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 connect that had no effect on initialization, + * because they depend on content children (e.g. column group `selected`). Retried once content queries resolve. + */ + private pendingContentValues = new Map(); /** * Resolvable component reference. @@ -255,6 +260,8 @@ 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); @@ -310,6 +317,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { * 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. + * Those that don't take effect yet are retried once content queries resolve. */ protected override initializeInputs(): void { const initialInputValues = (this as any).initialInputValues as Map; @@ -319,8 +327,24 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { for (const [property, value] of entries.filter(([prop]) => this._inputMap.has(prop))) { this.setInputValue(property, value); } + const instance = (this as any).componentRef.instance; for (const [property, value] of entries.filter(([prop]) => !this._inputMap.has(prop))) { this.setInputValue(property, value); + if (instance[property] !== value) { + this.pendingContentValues.set(property, value); + } + } + } + + /** 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); } } @@ -379,7 +403,8 @@ 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.applyPendingContentValues(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } } @@ -502,8 +527,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; From 575229da7c395781055ff93821ad6c6500ba9548 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:03:04 +0300 Subject: [PATCH 14/22] test(elements, extras): adding more tests for additional coverage --- .../src/app/create-custom-element.spec.ts | 160 +++++++++ .../src/app/custom-strategy-deferred.spec.ts | 48 +++ .../src/app/custom-strategy.spec.ts | 326 +++++++++++++++++- .../src/app/ng-element-strategy.spec.ts | 187 ++++++++++ .../src/app/wrapper/wrapper.component.spec.ts | 87 ++++- .../src/lib/exporters.spec.ts | 63 ++++ .../src/lib/icon.broadcast.service.spec.ts | 50 ++- .../src/lib/state.component.spec.ts | 148 ++++++++ .../src/utils/register.spec.ts | 82 +++++ .../chart-dialog.component.spec.ts | 111 +++++- .../context-menu.component.spec.ts | 324 ++++++++++++++++- .../igx-context-menu.directive.spec.ts | 116 +++++++ .../chart-integration.directive.spec.ts | 117 ++++++- .../chart-integration/initializers.spec.ts | 91 ++++- .../conditional-formatting.directive.spec.ts | 74 ++++ 15 files changed, 1972 insertions(+), 12 deletions(-) create mode 100644 projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/exporters.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/state.component.spec.ts create mode 100644 projects/igniteui-angular-elements/src/utils/register.spec.ts 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 new file mode 100644 index 00000000000..1cbc3feab70 --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts @@ -0,0 +1,160 @@ +import { Component, Input } from '@angular/core'; +import { + FilteringExpressionsTree, + FilteringLogic, + IgxBooleanFilteringOperand, + IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxGridComponent, + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxTimeFilteringOperand +} from 'igniteui-angular'; +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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-custom-element-test', + template: `{{ value }}`, + standalone: true +}) +class CustomElementTestComponent { + @Input() + public value: string; + + @Input() + public showUI: boolean; + + public counter = 0; + public readonlyProp = 'readonly'; + public writableProp = 'writable'; + + public increment(by: number) { + this.counter += by; + return this.counter; + } +} + +const TEST_TAG = 'igc-custom-element-test'; + +const createConfig = (overrides: Partial = {}): ComponentConfig[] => [{ + component: CustomElementTestComponent, + selector: TEST_TAG, + parents: [], + contentQueries: [], + additionalProperties: [ + { name: 'readonlyProp' }, + { name: 'writableProp', writable: true } + ], + methods: ['increment'], + ...overrides +}]; + +describe('createIgxCustomElement', () => { + const elementCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + if (!customElements.get(TEST_TAG)) { + customElements.define(TEST_TAG, elementCtor); + } + + let element: IgcNgElement & CustomElementTestComponent; + + beforeEach(() => { + element = document.createElement(TEST_TAG) as IgcNgElement & CustomElementTestComponent; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + vi.restoreAllMocks(); + }); + + const getInstance = async () => + (await element.ngElementStrategy[ComponentRefKey]).instance as CustomElementTestComponent; + + it('should expose the configured selector as static tagName', () => { + expect(elementCtor.tagName).toBe(TEST_TAG); + expect(Object.getOwnPropertyDescriptor(elementCtor, 'tagName').writable).toBe(false); + }); + + it('should proxy configured methods to the component instance and schedule change detection', async () => { + const instance = await getInstance(); + const notifySpy = vi.spyOn(element.ngElementStrategy, 'notifyChanges'); + + expect(element.increment(2)).toBe(2); + expect(element.increment(3)).toBe(5); + + expect(instance.counter).toBe(5); + expect(notifySpy).toHaveBeenCalledTimes(2); + }); + + it('should expose additional properties as getters on the element', async () => { + const instance = await getInstance(); + + expect(element.readonlyProp).toBe('readonly'); + instance.readonlyProp = 'changed'; + expect(element.readonlyProp).toBe('changed'); + }); + + it('should only allow setting writable additional properties', async () => { + const instance = await getInstance(); + const setInputSpy = vi.spyOn(element.ngElementStrategy, 'setInputValue').mockImplementation(() => {}); + + element.writableProp = 'updated'; + expect(setInputSpy).toHaveBeenCalledExactlyOnceWith('writableProp', 'updated'); + + expect(() => (element as any).readonlyProp = 'nope').toThrow(TypeError); + expect(instance.readonlyProp).toBe('readonly'); + }); + + it('should throw for additional properties that clash with the element prototype', () => { + const config = createConfig({ additionalProperties: [{ name: 'connectedCallback' }] }); + + expect(() => createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: config })) + .toThrowError(`Potentially illegal property name connectedCallback defined for ${CustomElementTestComponent.name}`); + }); + + it('should add the deprecated getFilterFactory to IgxGridComponent elements', () => { + const config: ComponentConfig[] = [{ + component: IgxGridComponent, + selector: 'igc-filter-factory-test-grid', + parents: [], + contentQueries: [], + additionalProperties: [], + methods: [] + }]; + const gridCtor = createIgxCustomElement(IgxGridComponent, { injector, registerConfig: config }); + const otherCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + + expect(otherCtor.prototype.getFilterFactory).toBeUndefined(); + + const factory = gridCtor.prototype.getFilterFactory(); + expect(factory.stringFilteringOperand).toBe(IgxStringFilteringOperand.instance()); + expect(factory.numberFilteringOperand).toBe(IgxNumberFilteringOperand.instance()); + expect(factory.timeFilteringOperand).toBe(IgxTimeFilteringOperand.instance()); + expect(factory.dateTimeFilteringOperand).toBe(IgxDateTimeFilteringOperand.instance()); + expect(factory.dateFilteringOperand).toBe(IgxDateFilteringOperand.instance()); + expect(factory.booleanFilteringOperand).toBe(IgxBooleanFilteringOperand.instance()); + + const tree = factory.createFilteringExpressionTree(FilteringLogic.Or, 'Name'); + expect(tree).toBeInstanceOf(FilteringExpressionsTree); + expect(tree.operator).toBe(FilteringLogic.Or); + expect(tree.fieldName).toBe('Name'); + }); +}); + +describe('withRegister', () => { + it('should attach a non-writable static register function', () => { + const ctor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + const register = vi.fn(); + + const component = withRegister(ctor, register); + component.register(); + + expect(component).toBe(ctor); + expect(register).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(component, 'register').writable).toBe(false); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts new file mode 100644 index 00000000000..a008cb8ff8a --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -0,0 +1,48 @@ +import { firstValueFrom, fromEvent } from 'rxjs'; +import { ComponentRefKey, IgcNgElement } from './custom-strategy'; +import { IgcColumnComponent, IgcTreeGridComponent } from './components'; +import { defineComponents } from '../utils/register'; +import { IgxTreeGridComponent } from '../lib/grids/tree-grid.component'; +import { describe, it, expect } from 'vitest'; + +describe('Elements: deferred parent definition', () => { + it('should wait for the parent element to be defined before attaching to it', async () => { + // Relies on the per-file isolation of the test runner for a clean custom elements registry + expect(customElements.get('igc-tree-grid')).toBeUndefined(); + defineComponents(IgcColumnComponent); + + const container = document.createElement('div'); + document.body.appendChild(container); + try { + container.innerHTML = ` + + + + `; + const treeGridEl = container.querySelector('#treeGrid'); + const columnEls = Array.from(container.querySelectorAll('igc-column')); + + // columns connect first, but have to wait for their parent to be defined + let columnsResolved = false; + const columnRefs = Promise.all(columnEls.map(c => c.ngElementStrategy[ComponentRefKey])) + .then(refs => { + columnsResolved = true; + return refs; + }); + await Promise.resolve(); + expect(columnsResolved).toBe(false); + + const childrenResolved = firstValueFrom(fromEvent(treeGridEl, 'childrenResolved')); + defineComponents(IgcTreeGridComponent); + + const treeGrid = (await (treeGridEl as IgcNgElement).ngElementStrategy[ComponentRefKey]).instance as IgxTreeGridComponent; + const columns = (await columnRefs).map(ref => ref.instance); + await childrenResolved; + + expect(treeGrid.columnList.toArray()).toEqual(columns); + expect(treeGrid.columns.map(c => c.field)).toEqual(['ID', 'Name']); + } finally { + container.remove(); + } + }); +}); 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 8e1a286b23b..add696e8bc6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,5 +1,5 @@ import { ApplicationRef, ViewContainerRef } from '@angular/core'; -import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType } from 'igniteui-angular'; +import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; @@ -23,9 +23,11 @@ import { IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, IgcGridToolbarHidingComponent, + IgcTreeGridComponent, } from './components'; +import { TemplateRefWrapper } from './wrapper/template-ref-wrapper'; import { defineComponents } from '../utils/register'; -import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; +import { describe, beforeAll, beforeEach, afterEach, it, expect, vi } from 'vitest'; describe('Elements: ', () => { let testContainer: HTMLDivElement; @@ -47,7 +49,8 @@ describe('Elements: ', () => { IgcGridToolbarActionsComponent, IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, - IgcGridToolbarHidingComponent + IgcGridToolbarHidingComponent, + IgcTreeGridComponent ); }); @@ -581,5 +584,322 @@ describe('Elements: ', () => { highlightedCell = gridEl.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); expect(highlightedCell).toBeNull(); }); + + it('should emit columnsAutogenerated and selectedRowsChange for tree grid', async () => { + const treeGridEl = document.createElement('igc-tree-grid') as IgcNgElement & InstanceType; + treeGridEl.autoGenerate = true; + treeGridEl.primaryKey = 'ID'; + treeGridEl.childDataKey = 'Employees'; + + 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']); + + const selectionChanged = firstValueFrom(fromEvent(treeGridEl, 'selectedRowsChange')); + treeGridEl.selectRows([147]); + expect((await selectionChanged).detail).toEqual([147]); + }); + + it('should emit columnsAutogenerated and selectedRowsChange for grid', async () => { + const gridEl = document.createElement('igc-grid'); + gridEl.autoGenerate = true; + gridEl.primaryKey = 'ProductID'; + gridEl.data = SampleTestData.foodProductData(); + const autoGenerated = firstValueFrom(fromEvent(gridEl, 'columnsAutogenerated')); + + testContainer.appendChild(gridEl); + + const columns = (await autoGenerated).detail.columns as IgxColumnComponent[]; + expect(columns.map(c => c.field)).toContain('ProductName'); + + const selectionChanged = firstValueFrom(fromEvent(gridEl, 'selectedRowsChange')); + gridEl.selectRows([1, 2]); + expect((await selectionChanged).detail).toEqual([1, 2]); + }); + }); + + describe('Element property handling.', () => { + let gridEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + + beforeEach(async () => { + gridEl = document.createElement('igc-grid') as IgcNgElement & InstanceType; + testContainer.appendChild(gridEl); + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + }); + + it('should wrap template functions in template refs and return the original function', async () => { + const emptyTemplate = () => html`Nothing here`; + + gridEl.emptyGridTemplate = emptyTemplate as any; + + const templateRef = grid.emptyGridTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(gridEl.emptyGridTemplate).toBe(emptyTemplate); + + // setting the same function again should keep the same template ref + gridEl.emptyGridTemplate = emptyTemplate as any; + expect(grid.emptyGridTemplate).toBe(templateRef); + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(gridEl.querySelector('.test-empty')?.textContent).toBe('Nothing here'); + + const otherTemplate = () => html`Still nothing`; + gridEl.emptyGridTemplate = otherTemplate as any; + expect(grid.emptyGridTemplate).not.toBe(templateRef); + expect(gridEl.emptyGridTemplate).toBe(otherTemplate); + }); + + it('should tie Elements rendered in templates to the component lifecycle', async () => { + gridEl.emptyGridTemplate = (() => html``) as any; + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + + const templatePaginator = gridEl.querySelector('#templatePaginator'); + expect(templatePaginator).toBeTruthy(); + expect((templatePaginator.ngElementStrategy as any).angularParent.instance).toBe(grid); + }); + + it('should coerce bool and numeric props', 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; + + (columnEl as any).hidden = 'true'; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = 'false'; + expect(column.hidden).toBe(false); + (columnEl as any).hidden = ''; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = null; + expect(column.hidden).toBe(false); + + (columnEl as any).rowStart = '2'; + expect(column.rowStart).toBe(2); + (columnEl as any).rowStart = 'not a number'; + expect(column.rowStart).toBe('not a number'); + }); + + it('should hide opened toggle components in the grid on grid scroll', () => { + const createToggle = () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + return toggle; + }; + const toggle = createToggle(); + const nonToggle = createToggle(); + const detachedToggle = createToggle(); + + const scroll = () => gridEl.dispatchEvent(new CustomEvent('gridScroll')); + const open = (el: HTMLElement, detail = {}) => el.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail })); + + // nothing opened, nothing to hide + scroll(); + + open(toggle); + open(nonToggle, { owner: 'combo' }); + open(detachedToggle); + detachedToggle.remove(); + scroll(); + + expect(toggle.hide).toHaveBeenCalledTimes(1); + expect(nonToggle.hide).not.toHaveBeenCalled(); + expect(detachedToggle.hide).not.toHaveBeenCalled(); + + // opened toggles are cleared after hiding + scroll(); + expect(toggle.hide).toHaveBeenCalledTimes(1); + }); + + it('should stop handling grid popups once the grid is destroyed', async () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + toggle.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail: {} })); + + gridEl.remove(); + await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + gridEl.dispatchEvent(new CustomEvent('gridScroll')); + + expect(toggle.hide).not.toHaveBeenCalled(); + }); + }); + + describe('Event args component proxies.', () => { + let gridEl: IgcNgElement & InstanceType; + let strategy: any; + let column: IgxColumnComponent; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + strategy = gridEl.ngElementStrategy; + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + column = gridEl.columns[0] as IgxColumnComponent; + }); + + it('should wrap components with template props in a proxy that converts templates', () => { + const { value: proxy, hasProxies } = strategy.createProxyForComponentValue(column, 1); + expect(hasProxies).toBe(true); + expect(proxy).not.toBe(column); + + const headerTemplate = () => html`Header`; + proxy.headerTemplate = headerTemplate; + const templateRef = column.headerTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(strategy.templateWrapper.getTemplateFunction(templateRef)).toBe(headerTemplate); + + // same template function keeps the existing ref + proxy.headerTemplate = headerTemplate; + expect(column.headerTemplate).toBe(templateRef); + + // non-template props are set as is + proxy.header = 'Custom header'; + expect(column.header).toBe('Custom header'); + }); + + it('should replace components nested in event args objects with proxies', () => { + const args = { column, owner: null, nested: { column } }; + + const { value, hasProxies } = strategy.createProxyForComponentValue(args, 1); + + expect(hasProxies).toBe(true); + expect(value).toBe(args); + expect(value.owner).toBeNull(); + expect(value.column).not.toBe(column); + expect(value.column.field).toBe(column.field); + expect(value.nested.column).not.toBe(column); + }); + + 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; + const event = new Event('test'); + const deep = { a: { b: { c: { column } } } }; + const plainArray = [{ field: 'ProductID' }]; + + expect(strategy.createProxyForComponentValue(column, 4)).toEqual({ value: column, hasProxies: false }); + expect(strategy.createProxyForComponentValue(event, 1)).toEqual({ value: event, hasProxies: false }); + expect(strategy.createProxyForComponentValue([], 1)).toEqual({ value: [], hasProxies: false }); + expect(strategy.createProxyForComponentValue(plainArray, 1)).toEqual({ value: plainArray, hasProxies: false }); + expect(strategy.createProxyForComponentValue(paginatorInstance, 1)).toEqual({ value: paginatorInstance, hasProxies: false }); + + const { hasProxies } = strategy.createProxyForComponentValue(deep, 1); + expect(hasProxies).toBe(false); + expect(deep.a.b.c.column).toBe(column); + }); + + it('should only patch columnInit 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); + }); + }); + + describe('Hierarchical grid integration.', () => { + let hgridEl: IgcNgElement & InstanceType; + let rowIslandEl: IgcNgElement & InstanceType; + let hgrid: IgxHierarchicalGridComponent; + + const waitFor = async (condition: () => boolean) => { + for (let waited = 0; waited < 3000 && !condition(); waited += 20) { + await firstValueFrom(timer(20)); + } + }; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + + `; + hgridEl = testContainer.querySelector('#testHGrid'); + rowIslandEl = testContainer.querySelector('#developers'); + hgrid = (await hgridEl.ngElementStrategy[ComponentRefKey]).instance; + await rowIslandEl.ngElementStrategy[ComponentRefKey]; + hgrid.data = hgridData; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + const expandFirstRow = async () => { + hgrid.expandRow(hgridData[0].ProjectId); + await waitFor(() => hgrid.gridAPI.getChildGrids(false).length > 0); + return hgrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + }; + + it('should re-emit child grid events on the row island with the child grid as owner', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.columns.map(c => c.field)).toContain('DeveloperId'); + + // events without args are re-emitted with the owner only + const rangeSelected = firstValueFrom(fromEvent(rowIslandEl, 'rangeSelected')); + childGrid.rangeSelected.emit(); + expect((await rangeSelected).detail).toEqual({ owner: childGrid }); + + const sortingDone = firstValueFrom(fromEvent(rowIslandEl, 'sortingDone')); + childGrid.sort({ fieldName: 'DeveloperId', dir: SortingDirection.Desc, ignoreCase: false }); + const sortingArgs = (await sortingDone).detail; + expect(sortingArgs.owner).toBe(childGrid); + }); + + it('should emit selectedRowsChange for hierarchical grid', async () => { + const selectionChanged = firstValueFrom(fromEvent(hgridEl, 'selectedRowsChange')); + hgridEl.selectRows([hgridData[0].ProjectId]); + + expect((await selectionChanged).detail).toEqual([hgridData[0].ProjectId]); + }); + + it('should apply row island input changes to existing child grids', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.allowFiltering).toBe(false); + + rowIslandEl.allowFiltering = true; + await waitFor(() => childGrid.allowFiltering); + + expect(childGrid.allowFiltering).toBe(true); + }); + + it('should render the row island paginator template in child grids and keep it in the root paginator query', async () => { + rowIslandEl.paginatorTemplate = (() => html``) as any; + const childGrid = await expandFirstRow(); + + await waitFor(() => !!childGrid.paginator && hgrid.paginatorList?.length > 0); + + const paginatorEl = childGrid.nativeElement.querySelector('igc-paginator'); + expect(paginatorEl).toBeTruthy(); + const paginator = (await paginatorEl.ngElementStrategy[ComponentRefKey]).instance; + expect(childGrid.paginator).toBe(paginator); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((hgridEl.ngElementStrategy as any).cachedChildComponents.get('paginatorList')).toContain(paginator); + await waitFor(() => childGrid.dataView.length === 2); + expect(childGrid.dataView.length).toBe(2); + + // collapsing detaches the cached child grid from the DOM, the paginator stays registered and alive + hgrid.collapseRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(paginatorEl.isConnected).toBe(false); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((paginatorEl.ngElementStrategy as any).componentRef).toBeTruthy(); + + hgrid.expandRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(childGrid.paginator).toBe(paginator); + }); }); }); diff --git a/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts new file mode 100644 index 00000000000..a93a8c48a0b --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts @@ -0,0 +1,187 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgElementStrategyEvent } from '@angular/elements'; +import { firstValueFrom, timer } from 'rxjs'; +import { ComponentNgElementStrategy, ComponentNgElementStrategyFactory, extractProjectableNodes } from './ng-element-strategy'; +import { injector } from '../utils/injector-ref'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-strategy-test', + template: `
{{ value }}
`, + standalone: true +}) +class StrategyTestComponent { + @Input() + public value: string; + + @Input('aliasName') + public aliased: string; + + @Output() + public valueChanged = new EventEmitter(); + + public destroyed = false; + + public ngOnDestroy() { + this.destroyed = true; + } +} + +/** DESTROY_DELAY in ng-element-strategy */ +const DESTROY_DELAY = 10; + +describe('ComponentNgElementStrategy', () => { + let host: HTMLElement; + let strategy: ComponentNgElementStrategy; + + const componentRef = () => (strategy as any).componentRef; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + strategy = new ComponentNgElementStrategyFactory(StrategyTestComponent).create(injector); + }); + + afterEach(() => { + strategy.disconnect(); + host.remove(); + }); + + it('should create strategies that map input property names to template names', () => { + const factory = new ComponentNgElementStrategyFactory(StrategyTestComponent); + + expect(factory.create(injector)).toBeInstanceOf(ComponentNgElementStrategy); + expect(factory.inputMap.get('value')).toBe('value'); + expect(factory.inputMap.get('aliased')).toBe('aliasName'); + }); + + it('should cache input values set before connect and apply them on connect', () => { + strategy.setInputValue('value', 'initial'); + strategy.setInputValue('aliased', 'aliased initial'); + + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBe('initial'); + + strategy.connect(host); + + const instance = componentRef().instance as StrategyTestComponent; + expect(instance.value).toBe('initial'); + expect(instance.aliased).toBe('aliased initial'); + expect(host.querySelector('.value').textContent).toBe('initial'); + expect((strategy as any).initialInputValues.size).toBe(0); + }); + + it('should set inputs on the component through their template name after connect', () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + expect(strategy.getInputValue('aliased')).toBe('new value'); + + // setting an unchanged value should leave the view clean + componentRef().hostView.detectChanges(); + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + }); + + it('should emit component outputs through the events stream', () => { + const events: NgElementStrategyEvent[] = []; + const sub = strategy.events.subscribe(e => events.push(e)); + strategy.connect(host); + + (componentRef().instance as StrategyTestComponent).valueChanged.emit('changed'); + expect(events).toEqual([{ name: 'valueChanged', value: 'changed' }]); + + sub.unsubscribe(); + (componentRef().instance as StrategyTestComponent).valueChanged.emit('ignored'); + expect(events.length).toBe(1); + }); + + it('should project child nodes into the matching content slots', () => { + const header = document.createElement('header'); + header.textContent = 'Header'; + const span = document.createElement('span'); + span.textContent = 'Content'; + host.appendChild(span); + host.appendChild(header); + host.appendChild(document.createTextNode('Text')); + + strategy.connect(host); + + expect(host.querySelector('.header').contains(header)).toBe(true); + expect(host.querySelector('.rest').contains(span)).toBe(true); + expect(host.querySelector('.rest').textContent).toBe('ContentText'); + }); + + it('should destroy the component with a delay after disconnect', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + // second disconnect while already scheduled is a no-op + strategy.disconnect(); + expect(instance.destroyed).toBe(false); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(true); + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBeUndefined(); + }); + + it('should keep the component when reconnected before the scheduled destroy', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + strategy.connect(host); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(false); + expect(componentRef().instance).toBe(instance); + }); + + it('should not schedule destroy when disconnected before connect', async () => { + strategy.disconnect(); + expect((strategy as any).scheduledDestroyFn).toBeNull(); + }); +}); + +describe('extractProjectableNodes', () => { + const createHost = (...children: Node[]) => { + const host = document.createElement('div'); + children.forEach(c => host.appendChild(c)); + return host; + }; + + it('should distribute nodes by selector and default the rest to the wildcard slot', () => { + const header = document.createElement('header'); + const footer = document.createElement('div'); + footer.className = 'footer'; + const other = document.createElement('span'); + const text = document.createTextNode('text'); + const host = createHost(header, other, footer, text); + + const nodes = extractProjectableNodes(host, ['header', '*', '.footer']); + + expect(nodes).toEqual([[header], [other, text], [footer]]); + }); + + it('should drop nodes that match no selector when there is no wildcard slot', () => { + const header = document.createElement('header'); + const other = document.createElement('span'); + const host = createHost(other, document.createTextNode('text'), header); + + const nodes = extractProjectableNodes(host, ['header']); + + expect(nodes).toEqual([[header]]); + }); + + it('should return no slots for components without content projection', () => { + const host = createHost(document.createElement('span')); + + expect(extractProjectableNodes(host, [])).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts index 495a61669d7..5cd4a442289 100644 --- a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts +++ b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { html } from 'lit'; import { AsyncDirective, directive } from 'lit/async-directive.js'; import { TemplateWrapperComponent } from './wrapper.component'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { TemplateRefWrapper } from './template-ref-wrapper'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; const directiveLog = []; class ToLowerAsyncDirective extends AsyncDirective { @@ -64,4 +65,88 @@ describe('WrapperComponent', () => { embeddedView.destroy(); expect(directiveLog).toEqual(['render', 'disconnected']); }); + + it('should expose the context through a proxy with implicit and context props', () => { + let templateContext: any; + const context = { $implicit: 'implicit value', text: 'text value' }; + const templateRef = component.addTemplate((ctx) => { + templateContext = ctx; + return html`${ctx.implicit}`; + }); + const embeddedView = templateRef.createEmbeddedView(context); + embeddedView.detectChanges(); + + expect(templateContext.implicit).toBe('implicit value'); + expect(templateContext.text).toBe('text value'); + expect(templateContext.context).toBe(templateContext); + expect('implicit' in templateContext).toBe(true); + expect('text' in templateContext).toBe(false); + // not bridged, so the bridge props are not handled: + expect(templateContext.___contentId).toBeUndefined(); + expect(templateContext.___immediate).toBeUndefined(); + expect(embeddedView.rootNodes[0].textContent).toBe('implicit value'); + }); + + it('should init and teardown bridged templates with their content context', () => { + let templateContext: any; + const templateFunc: any = (ctx) => { + templateContext = ctx; + return html`Bridged`; + }; + templateFunc.___isBridged = true; + templateFunc.___onTemplateInit = vi.fn(); + templateFunc.___onTemplateTeardown = vi.fn(); + + const templateRef = component.addTemplate(templateFunc); + const embeddedView = templateRef.createEmbeddedView({}); + const root = embeddedView.rootNodes[0]; + + expect(templateFunc.___onTemplateInit).toHaveBeenCalledTimes(1); + const [initFunc, initRoot, contentContext] = templateFunc.___onTemplateInit.mock.calls[0]; + expect(initFunc).toBe(templateFunc); + expect(initRoot).toBe(root); + expect(contentContext.root).toBe(root); + expect(contentContext.templateFunction).toBe(templateFunc); + expect(contentContext._id).toBeTruthy(); + expect(root._id).toBe(contentContext._id); + expect((templateRef as any)._contentContext.get(contentContext._id)).toBe(contentContext); + + embeddedView.detectChanges(); + expect(templateContext.___contentId).toBe(contentContext._id); + expect(templateContext.___immediate).toBe(true); + expect(templateContext.___root).toBe(root); + + embeddedView.destroy(); + expect(templateFunc.___onTemplateTeardown).toHaveBeenCalledExactlyOnceWith(templateFunc, root, contentContext); + expect((templateRef as any)._contentContext.size).toBe(0); + }); + + it('should expose the inner template elementRef and ssrId', () => { + const templateRef = component.addTemplate(() => html``) as TemplateRefWrapper; + + expect(templateRef.elementRef).toBe(templateRef.innerTemplateRef.elementRef); + expect(templateRef.ssrId).toBe((templateRef.innerTemplateRef as any).ssrId); + }); + + it('should resolve the template function from a wrapped template ref', () => { + const first = () => html`1`; + const second = () => html`2`; + const firstRef = component.addTemplate(first) as TemplateRefWrapper; + const secondRef = component.addTemplate(second) as TemplateRefWrapper; + + expect(component.getTemplateFunction(firstRef)).toBe(first); + expect(component.getTemplateFunction(secondRef)).toBe(second); + expect(component.getTemplateFunction(null)).toBeUndefined(); + }); + + it('should emit templateRendered with the template container', () => { + const rendered: HTMLElement[] = []; + component.templateRendered.subscribe(container => rendered.push(container)); + const templateRef = component.addTemplate(() => html`Rendered`); + const embeddedView = templateRef.createEmbeddedView({}); + embeddedView.detectChanges(); + + expect(rendered).toEqual([embeddedView.rootNodes[0]]); + expect(rendered[0].textContent).toBe('Rendered'); + }); }); diff --git a/projects/igniteui-angular-elements/src/lib/exporters.spec.ts b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts new file mode 100644 index 00000000000..3be52ad3579 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts @@ -0,0 +1,63 @@ +import { IgxCsvExporterOptions, IgxCsvExporterService, IgxExcelExporterOptions, IgxExcelExporterService, CsvFileTypes } from 'igniteui-angular'; +import { IgcCsvExporterService } from './csv-exporter'; +import { IgcExcelExporterService } from './excel-exporter'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('Elements exporter services', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const gridInstance = { id: 'grid-instance' }; + const gridElement = { ngElementStrategy: { componentRef: { instance: gridInstance } } }; + + describe('IgcCsvExporterService', () => { + const options = new IgxCsvExporterOptions('Test', CsvFileTypes.CSV); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcCsvExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcCsvExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); + + describe('IgcExcelExporterService', () => { + const options = new IgxExcelExporterOptions('Test'); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcExcelExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcExcelExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); +}); 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 ac9573567d2..fc984090e92 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 @@ -3,7 +3,7 @@ import { ActionType, BroadcastIconsChangeMessage, IgxIconBroadcastService, SvgIc import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { IconMeta, IgxIconService } from 'igniteui-angular'; import { wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('Icon broadcast service', () => { let fixture: ComponentFixture; @@ -102,7 +102,53 @@ describe('Icon broadcast service', () => { expect(serviceRef.family).toBe("customCollection"); expect(serviceRef.name).toBe("customIcon"); }); - }) + + it('should ignore messages without collections or references.', async () => { + const iconService = fixture.componentInstance.iconService; + const addSvgSpy = vi.spyOn(iconService, 'addSvgIconFromText'); + const setRefSpy = vi.spyOn(iconService, 'setIconRef'); + + broadcastChannel.postMessage({ actionType: ActionType.SyncState }); + await wait(50); + + expect(addSvgSpy).not.toHaveBeenCalled(); + expect(setRefSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Page lifecycle', () => { + it('should close the channel on pagehide and re-create it on pageshow.', async () => { + const iconService = fixture.componentInstance.iconService; + await wait(50); + expect(events.length).toBe(1); + + globalThis.dispatchEvent(new Event('pagehide')); + const icon: Map = new Map([["hiddenIcon", { svg: buildIcon }]]); + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["hiddenCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "hiddenCollection")).toBeUndefined(); + + // hiding again with an already closed channel is a no-op + expect(() => globalThis.dispatchEvent(new Event('pagehide'))).not.toThrow(); + + globalThis.dispatchEvent(new Event('pageshow')); + await wait(50); + // re-created channel requests state sync again + // (services from previous tests listen to the global page events as well) + expect(events.length).toBeGreaterThan(1); + expect(events.every(e => e.actionType === ActionType.SyncState)).toBe(true); + + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["shownCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "shownCollection")).not.toBeUndefined(); + }); + }); }); @Component({ diff --git a/projects/igniteui-angular-elements/src/lib/state.component.spec.ts b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts new file mode 100644 index 00000000000..c1adcc7cfb9 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts @@ -0,0 +1,148 @@ +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; +import { firstValueFrom, timer } from 'rxjs'; +import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; +import { ComponentRefKey, IgcNgElement } from '../app/custom-strategy'; +import { IgcColumnComponent, IgcGridComponent, IgcGridStateComponent } from '../app/components'; +import { defineComponents } from '../utils/register'; +import { IgxGridComponent } from './grids/grid.component'; +import { IGridStateInfo, IgxGridStateComponent } from './state.component'; +import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; + +describe('Elements: Grid state component', () => { + let testContainer: HTMLDivElement; + let gridEl: IgcNgElement & InstanceType; + let stateEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + let state: IgxGridStateComponent; + + const sortingState = () => [{ fieldName: 'ProductID', dir: SortingDirection.Desc, ignoreCase: false }]; + + beforeAll(() => { + defineComponents(IgcGridComponent, IgcColumnComponent, IgcGridStateComponent); + }); + + beforeEach(async () => { + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + stateEl = testContainer.querySelector('igc-grid-state') as any; + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + state = (await stateEl.ngElementStrategy[ComponentRefKey]).instance; + gridEl.data = SampleTestData.foodProductData(); + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + afterEach(() => { + document.body.removeChild(testContainer); + }); + + it('should be bound to its parent grid', () => { + expect(state.grid).toBe(grid); + }); + + it('should get the state of all features by default', () => { + grid.sort(sortingState()); + + const gridState = stateEl.getState(); + + expect(gridState.sorting).toMatchObject(sortingState()); + expect(gridState.columns.map(c => c.field)).toEqual(['ProductID', 'ProductName']); + expect(gridState.filtering).toBeDefined(); + expect(gridState.rowSelection).toBeDefined(); + }); + + it('should get the state of only the requested features', () => { + grid.sort(sortingState()); + + expect(Object.keys(stateEl.getState('sorting'))).toEqual(['sorting']); + expect(Object.keys(stateEl.getState(['sorting', 'columns'])).sort()).toEqual(['columns', 'sorting']); + + const serialized = stateEl.getStateAsString(['sorting']); + expect(JSON.parse(serialized)).toEqual({ sorting: sortingState() }); + expect(Object.keys(JSON.parse(stateEl.getStateAsString())).length).toBeGreaterThan(1); + }); + + it('should apply state of all or only of the requested features', () => { + const gridState: IGridStateInfo = { + sorting: sortingState(), + moving: false + }; + + stateEl.applyState(gridState, ['moving']); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(false); + + stateEl.applyState(gridState); + expect(grid.sortingExpressions).toMatchObject(sortingState()); + expect(grid.dataView[0].ProductID).toBe(Math.max(...gridEl.data.map(x => x.ProductID))); + }); + + it('should emit stateParsed when applying state from string and apply modifications to the parsed state', async () => { + const parsedStates: IGridStateInfo[] = []; + stateEl.addEventListener('stateParsed', (e: CustomEvent) => { + parsedStates.push(structuredClone(e.detail)); + e.detail.sorting[0].dir = SortingDirection.Asc; + }); + + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState() })); + + expect(parsedStates).toEqual([{ sorting: sortingState() }]); + expect(grid.sortingExpressions[0].dir).toBe(SortingDirection.Asc); + + grid.clearSort(); + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState(), moving: true }), 'moving'); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(true); + }); + + it('should serialize Set search values of filtering expressions as arrays', () => { + const condition = IgxStringFilteringOperand.instance().condition('in'); + grid.filter('ProductName', new Set(['Chai', 'Chang']), condition); + + const serialized = JSON.parse(stateEl.getStateAsString(['filtering'])); + const expression = serialized.filtering.filteringOperands[0].filteringOperands[0]; + + expect(expression.searchVal).toEqual(['Chai', 'Chang']); + }); + + it('should omit externalObject values when serializing', () => { + const callback = (key: string, value: any) => (state as any).stringifyCallback(key, value); + const set = new Set([1, 2]); + + expect(callback('externalObject', { some: 'value' })).toBeUndefined(); + expect(callback('searchVal', set)).toEqual([1, 2]); + expect(callback('searchVal', 'text')).toBe('text'); + expect(callback('other', set)).toBe(set); + }); + + it('should apply the state through the element API from a grid in another container', async () => { + const otherContainer = document.createElement('div'); + document.body.appendChild(otherContainer); + try { + otherContainer.innerHTML = ` + + + + `; + const otherGridEl = otherContainer.querySelector('igc-grid'); + const otherStateEl = otherContainer.querySelector>('igc-grid-state'); + const otherGrid = (await otherGridEl.ngElementStrategy[ComponentRefKey]).instance as IgxGridComponent; + await otherStateEl.ngElementStrategy[ComponentRefKey]; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + grid.sort(sortingState()); + otherStateEl.applyStateFromString(stateEl.getStateAsString(['sorting'])); + + expect(otherGrid.sortingExpressions).toMatchObject(sortingState()); + } finally { + otherContainer.remove(); + } + }); +}); diff --git a/projects/igniteui-angular-elements/src/utils/register.spec.ts b/projects/igniteui-angular-elements/src/utils/register.spec.ts new file mode 100644 index 00000000000..8c0c62f3c56 --- /dev/null +++ b/projects/igniteui-angular-elements/src/utils/register.spec.ts @@ -0,0 +1,82 @@ +import { defineComponents, IgniteComponent, registerComponent } from './register'; +import { defineAllComponents } from './define-all'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +let uniqueId = 0; +const createComponent = (): IgniteComponent => { + const ctor = class extends HTMLElement { } as unknown as IgniteComponent; + ctor.tagName = `igc-register-test-${uniqueId++}`; + ctor.register = vi.fn(() => registerComponent(ctor)); + return ctor; +}; + +describe('Elements register utils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerComponent should define the component and register its dependencies first', () => { + const dependency = createComponent(); + const component = createComponent(); + const defineSpy = vi.spyOn(customElements, 'define'); + + registerComponent(component, dependency); + + expect(dependency.register).toHaveBeenCalledTimes(1); + expect(customElements.get(dependency.tagName)).toBe(dependency); + expect(customElements.get(component.tagName)).toBe(component); + expect(defineSpy.mock.calls.map(([tag]) => tag)).toEqual([dependency.tagName, component.tagName]); + }); + + it('registerComponent should not re-define an already defined component', () => { + const component = createComponent(); + registerComponent(component); + const defineSpy = vi.spyOn(customElements, 'define'); + + expect(() => registerComponent(component)).not.toThrow(); + expect(defineSpy).not.toHaveBeenCalled(); + }); + + it('defineComponents should call register on every component', () => { + const components = [createComponent(), createComponent()]; + + defineComponents(...components); + + components.forEach(c => { + expect(c.register).toHaveBeenCalledTimes(1); + expect(customElements.get(c.tagName)).toBe(c); + }); + }); + + it('defineAllComponents should define all grid related elements', () => { + defineAllComponents(); + + const tags = [ + 'igc-grid', + 'igc-tree-grid', + 'igc-hierarchical-grid', + 'igc-pivot-grid', + 'igc-pivot-data-selector', + 'igc-row-island', + 'igc-column-group', + 'igc-column-layout', + 'igc-column', + 'igc-paginator', + 'igc-grid-toolbar', + 'igc-action-strip', + 'igc-grid-state', + 'igc-grid-editing-actions', + 'igc-grid-pinning-actions', + 'igc-grid-toolbar-title', + 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-hiding', + 'igc-grid-toolbar-pinning', + 'igc-grid-toolbar-exporter', + 'igc-grid-toolbar-advanced-filtering', + 'igc-query-builder', + 'igc-query-builder-header' + ]; + const undefinedTags = tags.filter(tag => !customElements.get(tag)); + expect(undefinedTags).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts index 5f5f396f5f7..cb2123bb691 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts @@ -1,7 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ElementRef } from '@angular/core'; import { IgxChartMenuComponent } from './chart-dialog.component'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartMenuComponent', () => { let component: IgxChartMenuComponent; @@ -234,3 +234,110 @@ describe('IgxChartMenuComponent', () => { }); }); }); + +describe('IgxChartMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxChartMenuComponent; + let chartDirective: { chartFactory: ReturnType }; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + const headerButtons = () => queryAll('.action-buttons-wrapper button'); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxChartMenuComponent] }); + fixture = TestBed.createComponent(IgxChartMenuComponent); + component = fixture.componentInstance; + chartDirective = { chartFactory: vi.fn() }; + component.chartDirective = chartDirective; + component.allCharts = ['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']; + component.width = 1000; + component.height = 500; + fixture.detectChanges(); + component.createChart('ColumnStacked'); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create the chart in the chart area and show its title in the header', () => { + expect(chartDirective.chartFactory).toHaveBeenCalledWith('ColumnStacked', component.chartArea); + expect(query('.header-text').textContent!.trim()).toBe('Column Stacked Chart'); + }); + + it('should bind the host size to 70% of the given dimensions and to 100% in full screen', () => { + const host: HTMLElement = fixture.nativeElement; + expect(host.style.width).toBe('700px'); + expect(host.style.height).toBe('350px'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(true); + expect(host.style.width).toBe('1000px'); + expect(host.style.height).toBe('500px'); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen_exit'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(false); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen'); + }); + + it('should emit closed when the close button is clicked', () => { + const closedSpy = vi.fn(); + component.closed.subscribe(closedSpy); + + headerButtons()[1].click(); + + expect(closedSpy).toHaveBeenCalledTimes(1); + }); + + it('should toggle the chart config area with the chevron button', () => { + const configArea = query('.chart-config-area'); + const toggleButton = query('.selection-area > div > button'); + expect(configArea.classList).toContain('opened'); + expect(query('.chart-area').classList).not.toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_right'); + + toggleButton.click(); + fixture.detectChanges(); + + expect(component.isConfigAreaExpanded).toBe(true); + expect(configArea.classList).toContain('closed'); + expect(configArea.classList).not.toContain('opened'); + expect(query('.chart-area').classList).toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_left'); + }); + + it('should render a section only for the main chart types that have available charts', () => { + const sections = queryAll('.wrapper') + .filter(w => w.querySelector('.types-section')) + .map(w => w.firstElementChild!.textContent!.trim()); + + expect(sections).toEqual(['Column Chart', 'Bar Chart', 'Pie Chart']); + }); + + it('should render a button per available chart and mark the current one as selected', () => { + const buttons = queryAll('.types-section-btn'); + + expect(buttons.map(b => b.title)).toEqual(['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']); + expect(buttons[1].classList).toContain('selected'); + expect(buttons[0].classList).not.toContain('selected'); + }); + + it('should create the chosen chart when a chart type button is clicked', () => { + const pieButton = queryAll('.types-section-btn').find(b => b.title === 'Pie')!; + + pieButton.click(); + fixture.detectChanges(); + + expect(chartDirective.chartFactory).toHaveBeenLastCalledWith('Pie', component.chartArea); + expect(component.currentChartType).toBe('Pie'); + expect(pieButton.classList).toContain('selected'); + expect(query('.header-text').textContent!.trim()).toBe('Pie Chart'); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts index ab82c899fbe..9c5662db7f9 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts @@ -1,6 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EventEmitter } from '@angular/core'; import { Subject } from 'rxjs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxOverlayService } from 'igniteui-angular/core'; import { IgxContextMenuComponent } from './context-menu.component'; import { CHART_TYPE } from '../directives/chart-integration/chart-types'; @@ -305,11 +306,104 @@ describe('IgxContextMenuComponent', () => { }); describe('overlayService.opening with chartDialogResizeNotify', () => { + let overlayElement: HTMLElement; + let resizeNotify: Subject; + + const createWrapper = (classes: string[], hidden = false) => { + const wrapper = document.createElement('div'); + wrapper.classList.add(...classes); + if (hidden) { + wrapper.style.visibility = 'hidden'; + } + const content = document.createElement('div'); + wrapper.appendChild(content); + overlayElement.appendChild(wrapper); + return content; + }; + + const openDialogWithResizeNotify = () => { + resizeNotify = new Subject(); + const mockInstance = { chartDialogResizeNotify: resizeNotify, closed: undefined } as any; + (mockOverlay.opening as Subject).next({ id: 'dialog-id', componentRef: { instance: mockInstance } }); + }; + + beforeEach(() => { + overlayElement = document.createElement('div'); + document.body.appendChild(overlayElement); + }); + + afterEach(() => { + overlayElement.remove(); + }); + it('should not throw when opening event has no componentRef', () => { expect(() => { (mockOverlay.opening as Subject).next({ componentRef: undefined }); }).not.toThrow(); }); + + it('should resize the content of the visible flex wrapper to the new dialog width', () => { + const hiddenContent = createWrapper(['igx-overlay__wrapper--flex'], true); + const visibleContent = createWrapper(['igx-overlay__wrapper--flex']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(mockOverlay.getOverlayById).toHaveBeenCalledWith('dialog-id'); + expect(visibleContent.style.width).toBe('420px'); + expect(hiddenContent.style.width).toBe(''); + }); + + it('should ignore wrappers that are not flex overlay wrappers', () => { + const content = createWrapper(['igx-overlay__wrapper']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(content.style.width).toBe(''); + }); + + it('should not throw when the flex wrapper has no content element', () => { + const wrapper = document.createElement('div'); + wrapper.classList.add('igx-overlay__wrapper--flex'); + overlayElement.appendChild(wrapper); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + + it('should not throw when the overlay has no element', () => { + mockOverlay.getOverlayById.mockReturnValue({ elementRef: undefined }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + }); + + describe('overlayService.closing subscription without an open dialog', () => { + it('should not reopen tabsMenu when the chart dialog closes and no dialog id is tracked', () => { + (mockOverlay.closing as Subject).next({ + componentRef: { instance: TestBed.createComponent(IgxChartMenuComponent).componentInstance } + }); + + expect(mockTabsMenu.open).not.toHaveBeenCalled(); + }); + }); + + describe('without optional chart and formatting directives', () => { + it('should initialize from the context directive without subscribing to missing directives', () => { + mockContextDirective.chartsDirective = null; + mockContextDirective.textFormatter = null; + mockContextDirective.charts = []; + mockContextDirective.formatters = []; + + expect(() => component.ngAfterViewInit()).not.toThrow(); + expect(component.chartTypes).toEqual([]); + expect(component.textFormatters).toEqual([]); + }); }); describe('constructor', () => { @@ -332,3 +426,231 @@ describe('IgxContextMenuComponent', () => { }); }); }); + +describe('IgxContextMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxContextMenuComponent; + let overlay: IgxOverlayService; + let contextDirective: any; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + + const createComponent = async (overrides: any = {}) => { + contextDirective = { + charts: [CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped, CHART_TYPE.Pie], + formatters: ['Top 10', 'Duplicate Values'], + displayCreationTab: true, + chartsDirective: { + chartTypesDetermined: new EventEmitter(), + chartFactory: vi.fn() + }, + textFormatter: { + formattersReady: new EventEmitter(), + formatCells: vi.fn(), + clearFormatting: vi.fn(), + formatter: undefined + }, + buttonClose: new EventEmitter(), + gridResizeNotify: new Subject(), + grid: { nativeElement: document.createElement('div') }, + ...overrides + }; + fixture = TestBed.createComponent(IgxContextMenuComponent); + component = fixture.componentInstance; + component.contextDirective = contextDirective; + // ngAfterViewInit copies these from the context directive after the view is checked; + // seed the same values up front so the first check is stable in dev mode + component.chartTypes = contextDirective.charts; + component.textFormatters = contextDirective.formatters; + component.displayCreationTab = contextDirective.displayCreationTab; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + const selectChartsTab = async () => { + component.tabs.selectedIndex = 1; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxContextMenuComponent, NoopAnimationsModule] }); + overlay = TestBed.inject(IgxOverlayService); + }); + + afterEach(() => { + component?.closeDialog(); + TestBed.resetTestingModule(); + }); + + it('should toggle the tabs menu when the analytics button is clicked', async () => { + await createComponent(); + expect(component.tabsMenu.collapsed).toBe(true); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(false); + expect(component.currentChartType).toBe(CHART_TYPE.ColumnGrouped); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should close an open tabs menu when the grid is resized', async () => { + await createComponent(); + component.toggleTabMenu(); + expect(component.tabsMenu.collapsed).toBe(false); + + contextDirective.gridResizeNotify.next(); + + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should not close the tabs menu on grid resize when it is already closed', async () => { + await createComponent(); + const closeSpy = vi.spyOn(component.tabsMenu, 'close'); + + contextDirective.gridResizeNotify.next(); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should render a formatting option per formatter and apply it on click', async () => { + await createComponent(); + const conditions = queryAll('.condition'); + + expect(conditions.length).toBe(2); + expect(conditions[0].querySelector('.name')!.textContent!.trim()).toBe('Top 10%'); + expect(conditions[1].querySelector('.name')!.textContent!.trim()).toBe('Duplicate Values'); + + conditions[1].click(); + fixture.detectChanges(); + + expect(contextDirective.textFormatter.formatCells).toHaveBeenCalledWith('Duplicate Values'); + expect(conditions[1].classList).toContain('selected--condition'); + expect(conditions[0].classList).not.toContain('selected--condition'); + }); + + it('should disable "Clear All" until a formatter is applied and clear formatting on click', async () => { + await createComponent(); + const clear = query('.clear'); + expect(clear.classList).toContain('disableButton'); + + contextDirective.textFormatter.formatter = 'Top 10'; + fixture.detectChanges(); + expect(clear.classList).not.toContain('disableButton'); + + component.currentFormatter = 'Top 10'; + clear.click(); + + expect(contextDirective.textFormatter.clearFormatting).toHaveBeenCalled(); + expect(component.currentFormatter).toBeUndefined(); + }); + + it('should disable "Clear All" when there is no conditional formatting directive', async () => { + await createComponent({ textFormatter: null }); + + expect(query('.clear').classList).toContain('disableButton'); + }); + + it('should render at most four chart options and mark the current chart as selected', async () => { + await createComponent(); + component.currentChartType = CHART_TYPE.BarGrouped; + await selectChartsTab(); + + const charts = queryAll('.action.chart'); + expect(charts.length).toBe(4); + expect(charts.map(c => c.querySelector('.name')!.textContent!.trim())) + .toEqual([CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped]); + expect(charts[1].classList).toContain('selected'); + expect(charts[0].classList).not.toContain('selected'); + expect(query('.more-action').classList).not.toContain('disableButton'); + }); + + it('should preview a chart on mouseenter and hide the preview on mouseleave', async () => { + await createComponent(); + component.toggleTabMenu(); + await selectChartsTab(); + const chart = queryAll('.action.chart')[2]; + + chart.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + + expect(component.currentChartType).toBe(CHART_TYPE.LineGrouped); + expect(contextDirective.chartsDirective.chartFactory).toHaveBeenCalledWith(CHART_TYPE.LineGrouped, component.chartPreview); + expect(component.chartPreviewDialog.collapsed).toBe(false); + + chart.dispatchEvent(new MouseEvent('mouseleave')); + fixture.detectChanges(); + + expect(component.chartPreviewDialog.collapsed).toBe(true); + }); + + it('should open the chart dialog configured from the context when a chart option is clicked', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + component.toggleTabMenu(); + await selectChartsTab(); + + queryAll('.action.chart')[1].click(); + fixture.detectChanges(); + + // the first opening is the tabs menu toggle, which has no componentRef + const dialogArgs = openingSpy.mock.calls.map(c => c[0]).find(args => args.componentRef); + const dialog = dialogArgs.componentRef.instance as IgxChartMenuComponent; + expect(dialog).toBeInstanceOf(IgxChartMenuComponent); + expect(dialog.currentChartType).toBe(CHART_TYPE.BarGrouped); + expect(dialog.allCharts).toBe(component.chartTypes); + expect(dialog.chartDirective).toBe(contextDirective.chartsDirective); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should open the dialog with the current chart type from "More..." and close it via the dialog close event', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + await selectChartsTab(); + component.currentChartType = CHART_TYPE.Pie; + + query('.more-action').click(); + fixture.detectChanges(); + + const dialogInfo = openingSpy.mock.calls[0][0]; + const dialog = dialogInfo.componentRef.instance as IgxChartMenuComponent; + expect(dialog.currentChartType).toBe(CHART_TYPE.Pie); + expect(overlay.getOverlayById(dialogInfo.id)).toBeDefined(); + + dialog.closed.emit(); + + expect(overlay.getOverlayById(dialogInfo.id)).toBeFalsy(); + }); + + it('should disable the chart creation tab when there are no charts', async () => { + await createComponent({ charts: [] }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(2); + expect(headers[0].getAttribute('aria-disabled')).toBe('false'); + expect(headers[1].getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not render the chart creation tab when displayCreationTab is false', async () => { + await createComponent({ displayCreationTab: false }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(1); + expect(headers[0].textContent!.trim()).toBe('Conditional Formatting'); + }); + + it('should select the chart creation tab when there are charts but no formatters', async () => { + await createComponent({ formatters: [] }); + + expect(component.tabs.selectedIndex).toBe(1); + expect(queryAll('.action.chart').length).toBe(4); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts index b13a0a2f872..886f12973fd 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts @@ -633,4 +633,120 @@ describe('IgxContextMenuDirective', () => { expect(mockOverlay.hide).toHaveBeenCalledWith('some-id'); }); }); + + describe('grid resize observer', () => { + it('should observe the grid element and forward resize notifications to gridResizeNotify', () => { + let observerCallback: () => void; + const observe = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { + observerCallback = cb; + } + public observe = observe; + public disconnect = vi.fn(); + }); + directive.ngOnDestroy(); + const fresh = TestBed.runInInjectionContext(() => new IgxContextMenuDirective()); + fresh.ngOnInit(); + fresh.ngAfterViewInit(); + const resizeSpy = vi.fn(); + fresh.gridResizeNotify.subscribe(resizeSpy); + + observerCallback!(); + + expect(observe).toHaveBeenCalledWith(mockGrid.nativeElement); + expect(resizeSpy).toHaveBeenCalledTimes(1); + fresh.ngOnDestroy(); + vi.unstubAllGlobals(); + }); + }); + + describe('column selection while the button is visible', () => { + const headerElement = document.createElement('div'); + + beforeEach(() => { + mockGrid.selectedColumns.mockReturnValue([ + { visibleIndex: 0, headerCell: { nativeElement: headerElement } } + ]); + (directive as any)._collapsed = false; + (directive as any)._id = 'some-id'; + }); + + it('should emit buttonClose without hiding when the selection change has no old selection', () => { + const closeSpy = vi.fn(); + directive.buttonClose.subscribe(closeSpy); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(mockOverlay.hide).not.toHaveBeenCalled(); + }); + + it('should reposition the existing overlay at the header cell and update its position strategy', () => { + const info = { settings: { positionStrategy: undefined } }; + mockOverlay.getOverlayById.mockReturnValue(info); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(mockOverlay.reposition).toHaveBeenCalledWith('some-id'); + expect(mockOverlay.show).not.toHaveBeenCalled(); + expect(info.settings.positionStrategy).toBe((directive as any)._analyticsBtnSettings.positionStrategy); + expect((directive as any)._analyticsBtnSettings.target).toBe(headerElement); + }); + }); + + describe('without optional chart and formatting directives', () => { + let bare: IgxContextMenuDirective; + + beforeEach(() => { + // detach the default directive so only the bare instance reacts to grid events + directive.ngOnDestroy(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IgxContextMenuDirective, + { provide: IgxGridComponent, useValue: mockGrid }, + { provide: IgxOverlayService, useValue: mockOverlay } + ] + }); + bare = TestBed.inject(IgxContextMenuDirective); + bare.ngOnInit(); + bare.ngAfterViewInit(); + }); + + afterEach(() => { + bare.ngOnDestroy(); + }); + + it('should have null textFormatter and chartsDirective', () => { + expect(bare.textFormatter).toBeNull(); + expect(bare.chartsDirective).toBeNull(); + expect(bare.formatters).toEqual([]); + expect(bare.charts).toEqual([]); + }); + + it('should still handle range selection and show the button', () => { + mockGrid.gridAPI.get_cell_by_index.mockReturnValue({ + row: { key: 0 }, + column: { index: 1 }, + nativeElement: document.createElement('div') + }); + + mockGrid.rangeSelected.next({ rowStart: 0, rowEnd: 1, columnStart: 0, columnEnd: 0 }); + vi.advanceTimersByTime(200); + + expect(mockGrid.getSelectedData).not.toHaveBeenCalled(); + expect(mockOverlay.show).toHaveBeenCalledWith('overlay-id'); + }); + + it('should still clear cell selection on column selection', () => { + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'], oldSelection: [] }); + vi.advanceTimersByTime(100); + + expect(mockGrid.clearCellSelection).toHaveBeenCalled(); + expect(mockGrid.getSelectedColumnsData).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts index c1dfc9dcd98..f4ecadfd707 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts @@ -1,6 +1,14 @@ +import { Component, ViewChild, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxColumnSeriesComponent, IgxDataChartCategoryModule, IgxDataChartComponent, + IgxDataChartCoreModule, IgxDataChartScatterModule, IgxDataChartStackedModule, IgxItemLegendComponent, + IgxItemLegendModule, IgxLegendComponent, IgxLegendModule, IgxNumericXAxisModule, IgxNumericYAxisModule, + IgxPieChartComponent, IgxPieChartModule, IgxStacked100BarSeriesComponent, IgxStackedColumnSeriesComponent +} from 'igniteui-angular-charts'; import { CHART_TYPE, OPTIONS_TYPE } from './chart-types'; import { IgxChartIntegrationDirective, IDeterminedChartTypesArgs } from './chart-integration.directive'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartIntegrationDirective', () => { let directive: IgxChartIntegrationDirective; @@ -442,4 +450,111 @@ describe('IgxChartIntegrationDirective', () => { expect(result).toBeDefined(); }); }); + + describe('chartFactory with a ViewContainerRef', () => { + @Component({ + template: ``, + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxDataChartScatterModule, IgxPieChartModule, IgxLegendModule, IgxItemLegendModule, + IgxNumericXAxisModule, IgxNumericYAxisModule, IgxCategoryXAxisModule, IgxCategoryYAxisModule + ] + }) + class ChartHostComponent { + @ViewChild('host', { read: ViewContainerRef, static: true }) + public host!: ViewContainerRef; + } + + let fixture: ComponentFixture; + let vcr: ViewContainerRef; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChartHostComponent] }); + fixture = TestBed.createComponent(ChartHostComponent); + fixture.detectChanges(); + vcr = fixture.componentInstance.host; + directive.chartData = [{ name: 'A', q1: 10, q2: 5 }, { name: 'B', q1: 20, q2: 15 }]; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create a pie chart with an item legend', () => { + let emitted: any; + directive.chartCreationDone.subscribe(c => emitted = c); + + const chart = directive.chartFactory(CHART_TYPE.Pie, vcr); + + expect(chart).toBeInstanceOf(IgxPieChartComponent); + expect(emitted).toBe(chart); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxItemLegendComponent); + expect(chart.valueMemberPath).toBe('q1'); + expect(chart.labelMemberPath).toBe('name'); + }); + + it('should create a data chart with one series per numeric column and a legend', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxLegendComponent); + expect(chart.series.count).toBe(2); + expect(chart.series.item(0)).toBeInstanceOf(IgxColumnSeriesComponent); + expect(chart.series.item(1).valueMemberPath).toBe('q2'); + expect(chart.axes.count).toBe(2); + }); + + it('should not create a legend when useLegend is false', () => { + directive.useLegend = false; + + const chart = directive.chartFactory(CHART_TYPE.LineGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(1); + expect(chart.legend).toBeFalsy(); + }); + + it('should create one stacked series with a fragment per numeric column', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('q1'); + expect(series.series.item(0).title).toBe('q1'); + expect(series.series.item(1).valueMemberPath).toBe('q2'); + }); + + it('should not apply the series model to stacked fragments', () => { + directive.setChartComponentOptions(CHART_TYPE.ColumnStacked, OPTIONS_TYPE.Series, { title: 'custom' }); + + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + const fragment = chart.series.item(0).series.item(0); + expect(fragment.title).toBe('q1'); + }); + + it('should use the label member path on the y axis for stacked bar charts', () => { + const chart = directive.chartFactory(CHART_TYPE.Bar100Stacked, vcr); + + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStacked100BarSeriesComponent); + expect(series.yAxis.label).toBe('name'); + expect(series.xAxis.label).toBeFalsy(); + }); + + it('should configure the bubble size scale when creating in a container', () => { + directive.chartData = [{ name: 'A', x: 1, y: 2, r: 3 }, { name: 'B', x: 4, y: 5, r: 6 }]; + + const chart = directive.chartFactory(CHART_TYPE.ScatterBubble, vcr); + + const series = chart.series.item(0); + expect(series.radiusMemberPath).toBe('y'); + expect(series.radiusScale.minimumValue).toBe(10); + expect(series.radiusScale.maximumValue).toBe(60); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts index 48681021e4e..2acb949af6a 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { IgxPieChartInitializer, IgxDataChartInitializer, @@ -13,9 +14,17 @@ import { IgxScatterLineSeriesComponent, IgxStackedColumnSeriesComponent, IgxStackedBarSeriesComponent, - IgxStacked100BarSeriesComponent + IgxStacked100BarSeriesComponent, + IgxDataChartComponent, + IgxDataChartCoreModule, + IgxDataChartCategoryModule, + IgxDataChartStackedModule, + IgxCategoryXAxisModule, + IgxCategoryYAxisModule, + IgxNumericXAxisModule, + IgxNumericYAxisModule } from 'igniteui-angular-charts'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Helper to create a mock chart with series and axes arrays function createMockDataChart() { @@ -220,4 +229,82 @@ describe('IgxStackedDataChartInitializer', () => { const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); expect(init.seriesType).toBe(IgxStackedBarSeriesComponent); }); + + describe('initChart', () => { + const stackedOptions = (): IChartComponentOptions => ({ + chartOptions: { width: '100%' }, + stackedFragmentOptions: [ + { valueMemberPath: 'val1', title: 'V1' }, + { valueMemberPath: 'val2', title: 'V2' } + ] as any, + xAxisOptions: { label: 'name' }, + yAxisOptions: { labelExtent: 40 } + }); + + let chart: IgxDataChartComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxNumericXAxisModule, IgxNumericYAxisModule + ] + }); + chart = TestBed.createComponent(IgxDataChartComponent).componentInstance; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should add a single stacked series with one fragment per fragment option', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('val1'); + expect(series.series.item(0).title).toBe('V1'); + expect(series.series.item(1).valueMemberPath).toBe('val2'); + }); + + it('should bind the series to the initializer axes and add them to the chart', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series.xAxis).toBe((init as any).xAxis); + expect(series.yAxis).toBe((init as any).yAxis); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + expect(chart.axes.item(1)).toBe((init as any).yAxis); + expect((init as any).xAxis.label).toBe('name'); + expect((init as any).yAxis.labelExtent).toBe(40); + }); + + it('should apply chart options and return the same chart instance', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + const result = init.initChart(chart, stackedOptions()); + + expect(result).toBe(chart); + expect(chart.width).toBe('100%'); + }); + + it('should replace existing series and axes when re-initializing a chart', () => { + new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent).initChart(chart, stackedOptions()); + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + expect(chart.series.item(0)).toBeInstanceOf(IgxStackedBarSeriesComponent); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts index 60d9009d267..c7775106c04 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts @@ -648,4 +648,78 @@ describe('IgxConditionalFormattingDirective', () => { expect(directive.formatColors.text).toBe('#FFF'); }); }); + + describe('duplicates and uniques with text values and out-of-range cells', () => { + beforeEach(() => { + (directive as any).formatedRange = new Map([[0, new Set([0])]]); + }); + + it('should return undefined color for duplicates when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 10 }]); + expect(directive.duplicates.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should return undefined color for uniques when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }]); + expect(directive.uniques.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should compare text values only against text data for duplicates', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.duplicates.backgroundColor(null, 0, 'A', 0)).toBe(directive.formatColors.info); + expect(directive.duplicates.color(null, 0, 'B', 0)).toBe(''); + }); + + it('should compare text values only against text data for uniques', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.uniques.backgroundColor(null, 0, 'B', 0)).toBe(directive.formatColors.info); + expect(directive.uniques.color(null, 0, 'A', 0)).toBe(''); + }); + }); + + describe('formatCells range handling', () => { + let col0: any, col1: any, col2: any; + + beforeEach(() => { + col0 = { visibleIndex: 0, cellStyles: undefined }; + col1 = { visibleIndex: 1, cellStyles: undefined }; + col2 = { visibleIndex: 2, cellStyles: undefined }; + mockGrid.visibleColumns = [col0, col1, col2]; + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 20 }]); + mockGrid.data = [{ val: 10 }, { val: 20 }]; + }); + + it('should use the provided formatRange instead of the grid selected ranges', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + const customRange: any = [{ rowStart: 1, rowEnd: 1, columnStart: 2, columnEnd: 2 }]; + + directive.formatCells(ConditionalFormattingType.ColorScale, customRange); + + expect(directive.isWithInFormattedRange(1, 2)).toBe(true); + expect(directive.isWithInFormattedRange(0, 0)).toBe(false); + }); + + it('should keep the existing formatted range when reset is false', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + directive.formatCells(ConditionalFormattingType.ColorScale); + + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 1, rowEnd: 1, columnStart: 1, columnEnd: 1 }]); + directive.formatCells(ConditionalFormattingType.Empty, undefined, false); + + expect(directive.isWithInFormattedRange(0, 0)).toBe(true); + expect(directive.isWithInFormattedRange(1, 1)).toBe(false); + expect(directive.formatter).toBe(ConditionalFormattingType.Empty); + }); + + it('should not notify the grid when no visible column falls into the range', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 1, columnStart: 5, columnEnd: 6 }]); + + directive.formatCells(ConditionalFormattingType.ColorScale); + + expect(mockGrid.notifyChanges).not.toHaveBeenCalled(); + expect(col0.cellStyles).toBeNull(); + expect(col1.cellStyles).toBeNull(); + expect(col2.cellStyles).toBeNull(); + }); + }); }); From 09e5e86ec30065a4c4c639917d37629e846d7c2f Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 22:25:17 +0300 Subject: [PATCH 15/22] Update test to check toggle visibility on grid scroll Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../igniteui-angular-elements/src/app/custom-strategy.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 add696e8bc6..13ce4cde1d6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -726,6 +726,7 @@ describe('Elements: ', () => { gridEl.remove(); await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + testContainer.appendChild(toggle); gridEl.dispatchEvent(new CustomEvent('gridScroll')); expect(toggle.hide).not.toHaveBeenCalled(); From 7090a1974763e76a32dbb08129fd45cf23b93150 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:11:36 +0300 Subject: [PATCH 16/22] test(elements): fixing the unit test by isolating in config --- angular.json | 1 + .../src/app/custom-strategy-deferred.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index e53bfb73fa3..d00812f770b 100644 --- a/angular.json +++ b/angular.json @@ -455,6 +455,7 @@ "buildTarget": "igniteui-angular-elements:build:development,test", "tsConfig": "projects/igniteui-angular-elements/tsconfig.spec.json", "browsers": ["chromium"], + "isolate": true, "coverageInclude": [ "projects/igniteui-angular-elements/src/**/*.ts", "projects/igniteui-angular-elements/src/**/*.html" diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts index a008cb8ff8a..ccf04ad8217 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -7,7 +7,8 @@ import { describe, it, expect } from 'vitest'; describe('Elements: deferred parent definition', () => { it('should wait for the parent element to be defined before attaching to it', async () => { - // Relies on the per-file isolation of the test runner for a clean custom elements registry + // Relies on per-file isolation (`isolate: true` in angular.json) for a clean custom elements registry, + // otherwise other spec files sharing the same browser page may have already defined `igc-tree-grid` expect(customElements.get('igc-tree-grid')).toBeUndefined(); defineComponents(IgcColumnComponent); From 344abaef9acb8b2be94768f02af9c86eee41811b Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 14:54:08 +0300 Subject: [PATCH 17/22] fix(elements): addressing the new copilot comment --- .../src/app/custom-strategy.spec.ts | 36 +++++++++++++++++++ .../src/app/custom-strategy.ts | 17 +++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 3be2cf7b9f5..255ecd0f271 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -737,6 +737,42 @@ describe('Elements: ', () => { 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; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index 9d96b115cd1..f95f957432f 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -34,10 +34,12 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { /** 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 connect that had no effect on initialization, + * Additional property values set before content queries resolve that had no effect, * because they 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 contentResolved = false; /** * Resolvable component reference. @@ -91,6 +93,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { return; } this.element = element as IgcNgElement; + this.contentResolved = false; + this.pendingContentValues.clear(); // set componentRef to non-null to prevent DOM moves from re-initializing // TODO: Fail handling or cancellation needed? @@ -187,6 +191,7 @@ 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.contentResolved = true; (this as any).componentRef?.instance?.childrenResolved?.emit(); } @@ -306,6 +311,10 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { 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 && componentRef.instance[property] !== value) { + // depends on content children that aren't resolved yet, retry once they are: + this.pendingContentValues.set(property, value); + } componentRef.changeDetectorRef.markForCheck(); this.notifyChanges(); return; @@ -317,7 +326,6 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { * 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. - * Those that don't take effect yet are retried once content queries resolve. */ protected override initializeInputs(): void { const initialInputValues = (this as any).initialInputValues as Map; @@ -327,12 +335,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { for (const [property, value] of entries.filter(([prop]) => this._inputMap.has(prop))) { this.setInputValue(property, value); } - const instance = (this as any).componentRef.instance; for (const [property, value] of entries.filter(([prop]) => !this._inputMap.has(prop))) { this.setInputValue(property, value); - if (instance[property] !== value) { - this.pendingContentValues.set(property, value); - } } } @@ -404,6 +408,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { } if (this.schedule.size === 0) { // children are attached and collections are updated, retry values that depend on them and emit event. + this.contentResolved = true; this.applyPendingContentValues(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } From d7c82f67f9f45ab9d1cdc4f0e88bc94fb2a8938d Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 16:18:24 +0300 Subject: [PATCH 18/22] Merge branch 'fix/elements-extras-bugs' of https://github.com/IgniteUI/igniteui-angular into fix/elements-extras-bugs --- .../src/app/custom-strategy.spec.ts | 23 +++++++++++++- .../src/app/custom-strategy.ts | 31 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 255ecd0f271..2289c07877f 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -229,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); @@ -791,6 +791,27 @@ describe('Elements: ', () => { 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 hide opened toggle components in the grid on grid scroll', () => { const createToggle = () => { const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index f95f957432f..8defcc80010 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -39,6 +39,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { */ 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; /** @@ -93,6 +95,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { return; } this.element = element as IgcNgElement; + this.queriesResolved = false; this.contentResolved = false; this.pendingContentValues.clear(); @@ -191,7 +194,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.contentResolved = true; + this.queriesResolved = true; + this.resolveContent(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } @@ -340,6 +344,27 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { } } + /** + * 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.hasUnresolvedQueryChildren()) { + return; + } + this.contentResolved = true; + this.applyPendingContentValues(); + this.parentElement?.deref()?.ngElementStrategy?.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) { @@ -408,8 +433,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { } if (this.schedule.size === 0) { // children are attached and collections are updated, retry values that depend on them and emit event. - this.contentResolved = true; - this.applyPendingContentValues(); + this.queriesResolved = true; + this.resolveContent(); (this as any).componentRef?.instance?.childrenResolved?.emit(); } } From ac05dbdb1254a3b4e6df662c46f81f311bd4bb6f Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 19:03:04 +0300 Subject: [PATCH 19/22] test(elements, extras): adding more tests for additional coverage --- .../src/app/create-custom-element.spec.ts | 160 +++++++++ .../src/app/custom-strategy-deferred.spec.ts | 48 +++ .../src/app/custom-strategy.spec.ts | 326 +++++++++++++++++- .../src/app/ng-element-strategy.spec.ts | 187 ++++++++++ .../src/app/wrapper/wrapper.component.spec.ts | 87 ++++- .../src/lib/exporters.spec.ts | 63 ++++ .../src/lib/icon.broadcast.service.spec.ts | 50 ++- .../src/lib/state.component.spec.ts | 148 ++++++++ .../src/utils/register.spec.ts | 82 +++++ .../chart-dialog.component.spec.ts | 111 +++++- .../context-menu.component.spec.ts | 324 ++++++++++++++++- .../igx-context-menu.directive.spec.ts | 116 +++++++ .../chart-integration.directive.spec.ts | 117 ++++++- .../chart-integration/initializers.spec.ts | 91 ++++- .../conditional-formatting.directive.spec.ts | 74 ++++ 15 files changed, 1972 insertions(+), 12 deletions(-) create mode 100644 projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts create mode 100644 projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/exporters.spec.ts create mode 100644 projects/igniteui-angular-elements/src/lib/state.component.spec.ts create mode 100644 projects/igniteui-angular-elements/src/utils/register.spec.ts 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 new file mode 100644 index 00000000000..1cbc3feab70 --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.spec.ts @@ -0,0 +1,160 @@ +import { Component, Input } from '@angular/core'; +import { + FilteringExpressionsTree, + FilteringLogic, + IgxBooleanFilteringOperand, + IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxGridComponent, + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxTimeFilteringOperand +} from 'igniteui-angular'; +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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-custom-element-test', + template: `{{ value }}`, + standalone: true +}) +class CustomElementTestComponent { + @Input() + public value: string; + + @Input() + public showUI: boolean; + + public counter = 0; + public readonlyProp = 'readonly'; + public writableProp = 'writable'; + + public increment(by: number) { + this.counter += by; + return this.counter; + } +} + +const TEST_TAG = 'igc-custom-element-test'; + +const createConfig = (overrides: Partial = {}): ComponentConfig[] => [{ + component: CustomElementTestComponent, + selector: TEST_TAG, + parents: [], + contentQueries: [], + additionalProperties: [ + { name: 'readonlyProp' }, + { name: 'writableProp', writable: true } + ], + methods: ['increment'], + ...overrides +}]; + +describe('createIgxCustomElement', () => { + const elementCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + if (!customElements.get(TEST_TAG)) { + customElements.define(TEST_TAG, elementCtor); + } + + let element: IgcNgElement & CustomElementTestComponent; + + beforeEach(() => { + element = document.createElement(TEST_TAG) as IgcNgElement & CustomElementTestComponent; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + vi.restoreAllMocks(); + }); + + const getInstance = async () => + (await element.ngElementStrategy[ComponentRefKey]).instance as CustomElementTestComponent; + + it('should expose the configured selector as static tagName', () => { + expect(elementCtor.tagName).toBe(TEST_TAG); + expect(Object.getOwnPropertyDescriptor(elementCtor, 'tagName').writable).toBe(false); + }); + + it('should proxy configured methods to the component instance and schedule change detection', async () => { + const instance = await getInstance(); + const notifySpy = vi.spyOn(element.ngElementStrategy, 'notifyChanges'); + + expect(element.increment(2)).toBe(2); + expect(element.increment(3)).toBe(5); + + expect(instance.counter).toBe(5); + expect(notifySpy).toHaveBeenCalledTimes(2); + }); + + it('should expose additional properties as getters on the element', async () => { + const instance = await getInstance(); + + expect(element.readonlyProp).toBe('readonly'); + instance.readonlyProp = 'changed'; + expect(element.readonlyProp).toBe('changed'); + }); + + it('should only allow setting writable additional properties', async () => { + const instance = await getInstance(); + const setInputSpy = vi.spyOn(element.ngElementStrategy, 'setInputValue').mockImplementation(() => {}); + + element.writableProp = 'updated'; + expect(setInputSpy).toHaveBeenCalledExactlyOnceWith('writableProp', 'updated'); + + expect(() => (element as any).readonlyProp = 'nope').toThrow(TypeError); + expect(instance.readonlyProp).toBe('readonly'); + }); + + it('should throw for additional properties that clash with the element prototype', () => { + const config = createConfig({ additionalProperties: [{ name: 'connectedCallback' }] }); + + expect(() => createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: config })) + .toThrowError(`Potentially illegal property name connectedCallback defined for ${CustomElementTestComponent.name}`); + }); + + it('should add the deprecated getFilterFactory to IgxGridComponent elements', () => { + const config: ComponentConfig[] = [{ + component: IgxGridComponent, + selector: 'igc-filter-factory-test-grid', + parents: [], + contentQueries: [], + additionalProperties: [], + methods: [] + }]; + const gridCtor = createIgxCustomElement(IgxGridComponent, { injector, registerConfig: config }); + const otherCtor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + + expect(otherCtor.prototype.getFilterFactory).toBeUndefined(); + + const factory = gridCtor.prototype.getFilterFactory(); + expect(factory.stringFilteringOperand).toBe(IgxStringFilteringOperand.instance()); + expect(factory.numberFilteringOperand).toBe(IgxNumberFilteringOperand.instance()); + expect(factory.timeFilteringOperand).toBe(IgxTimeFilteringOperand.instance()); + expect(factory.dateTimeFilteringOperand).toBe(IgxDateTimeFilteringOperand.instance()); + expect(factory.dateFilteringOperand).toBe(IgxDateFilteringOperand.instance()); + expect(factory.booleanFilteringOperand).toBe(IgxBooleanFilteringOperand.instance()); + + const tree = factory.createFilteringExpressionTree(FilteringLogic.Or, 'Name'); + expect(tree).toBeInstanceOf(FilteringExpressionsTree); + expect(tree.operator).toBe(FilteringLogic.Or); + expect(tree.fieldName).toBe('Name'); + }); +}); + +describe('withRegister', () => { + it('should attach a non-writable static register function', () => { + const ctor = createIgxCustomElement(CustomElementTestComponent, { injector, registerConfig: createConfig() }); + const register = vi.fn(); + + const component = withRegister(ctor, register); + component.register(); + + expect(component).toBe(ctor); + expect(register).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(component, 'register').writable).toBe(false); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts new file mode 100644 index 00000000000..a008cb8ff8a --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -0,0 +1,48 @@ +import { firstValueFrom, fromEvent } from 'rxjs'; +import { ComponentRefKey, IgcNgElement } from './custom-strategy'; +import { IgcColumnComponent, IgcTreeGridComponent } from './components'; +import { defineComponents } from '../utils/register'; +import { IgxTreeGridComponent } from '../lib/grids/tree-grid.component'; +import { describe, it, expect } from 'vitest'; + +describe('Elements: deferred parent definition', () => { + it('should wait for the parent element to be defined before attaching to it', async () => { + // Relies on the per-file isolation of the test runner for a clean custom elements registry + expect(customElements.get('igc-tree-grid')).toBeUndefined(); + defineComponents(IgcColumnComponent); + + const container = document.createElement('div'); + document.body.appendChild(container); + try { + container.innerHTML = ` + + + + `; + const treeGridEl = container.querySelector('#treeGrid'); + const columnEls = Array.from(container.querySelectorAll('igc-column')); + + // columns connect first, but have to wait for their parent to be defined + let columnsResolved = false; + const columnRefs = Promise.all(columnEls.map(c => c.ngElementStrategy[ComponentRefKey])) + .then(refs => { + columnsResolved = true; + return refs; + }); + await Promise.resolve(); + expect(columnsResolved).toBe(false); + + const childrenResolved = firstValueFrom(fromEvent(treeGridEl, 'childrenResolved')); + defineComponents(IgcTreeGridComponent); + + const treeGrid = (await (treeGridEl as IgcNgElement).ngElementStrategy[ComponentRefKey]).instance as IgxTreeGridComponent; + const columns = (await columnRefs).map(ref => ref.instance); + await childrenResolved; + + expect(treeGrid.columnList.toArray()).toEqual(columns); + expect(treeGrid.columns.map(c => c.field)).toEqual(['ID', 'Name']); + } finally { + container.remove(); + } + }); +}); 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 8e1a286b23b..add696e8bc6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,5 +1,5 @@ import { ApplicationRef, ViewContainerRef } from '@angular/core'; -import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType } from 'igniteui-angular'; +import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, PivotGridType, SortingDirection } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; @@ -23,9 +23,11 @@ import { IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, IgcGridToolbarHidingComponent, + IgcTreeGridComponent, } from './components'; +import { TemplateRefWrapper } from './wrapper/template-ref-wrapper'; import { defineComponents } from '../utils/register'; -import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; +import { describe, beforeAll, beforeEach, afterEach, it, expect, vi } from 'vitest'; describe('Elements: ', () => { let testContainer: HTMLDivElement; @@ -47,7 +49,8 @@ describe('Elements: ', () => { IgcGridToolbarActionsComponent, IgcGridToolbarTitleComponent, IgcGridToolbarPinningComponent, - IgcGridToolbarHidingComponent + IgcGridToolbarHidingComponent, + IgcTreeGridComponent ); }); @@ -581,5 +584,322 @@ describe('Elements: ', () => { highlightedCell = gridEl.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); expect(highlightedCell).toBeNull(); }); + + it('should emit columnsAutogenerated and selectedRowsChange for tree grid', async () => { + const treeGridEl = document.createElement('igc-tree-grid') as IgcNgElement & InstanceType; + treeGridEl.autoGenerate = true; + treeGridEl.primaryKey = 'ID'; + treeGridEl.childDataKey = 'Employees'; + + 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']); + + const selectionChanged = firstValueFrom(fromEvent(treeGridEl, 'selectedRowsChange')); + treeGridEl.selectRows([147]); + expect((await selectionChanged).detail).toEqual([147]); + }); + + it('should emit columnsAutogenerated and selectedRowsChange for grid', async () => { + const gridEl = document.createElement('igc-grid'); + gridEl.autoGenerate = true; + gridEl.primaryKey = 'ProductID'; + gridEl.data = SampleTestData.foodProductData(); + const autoGenerated = firstValueFrom(fromEvent(gridEl, 'columnsAutogenerated')); + + testContainer.appendChild(gridEl); + + const columns = (await autoGenerated).detail.columns as IgxColumnComponent[]; + expect(columns.map(c => c.field)).toContain('ProductName'); + + const selectionChanged = firstValueFrom(fromEvent(gridEl, 'selectedRowsChange')); + gridEl.selectRows([1, 2]); + expect((await selectionChanged).detail).toEqual([1, 2]); + }); + }); + + describe('Element property handling.', () => { + let gridEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + + beforeEach(async () => { + gridEl = document.createElement('igc-grid') as IgcNgElement & InstanceType; + testContainer.appendChild(gridEl); + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + }); + + it('should wrap template functions in template refs and return the original function', async () => { + const emptyTemplate = () => html`Nothing here`; + + gridEl.emptyGridTemplate = emptyTemplate as any; + + const templateRef = grid.emptyGridTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(gridEl.emptyGridTemplate).toBe(emptyTemplate); + + // setting the same function again should keep the same template ref + gridEl.emptyGridTemplate = emptyTemplate as any; + expect(grid.emptyGridTemplate).toBe(templateRef); + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(gridEl.querySelector('.test-empty')?.textContent).toBe('Nothing here'); + + const otherTemplate = () => html`Still nothing`; + gridEl.emptyGridTemplate = otherTemplate as any; + expect(grid.emptyGridTemplate).not.toBe(templateRef); + expect(gridEl.emptyGridTemplate).toBe(otherTemplate); + }); + + it('should tie Elements rendered in templates to the component lifecycle', async () => { + gridEl.emptyGridTemplate = (() => html``) as any; + + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + + const templatePaginator = gridEl.querySelector('#templatePaginator'); + expect(templatePaginator).toBeTruthy(); + expect((templatePaginator.ngElementStrategy as any).angularParent.instance).toBe(grid); + }); + + it('should coerce bool and numeric props', 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; + + (columnEl as any).hidden = 'true'; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = 'false'; + expect(column.hidden).toBe(false); + (columnEl as any).hidden = ''; + expect(column.hidden).toBe(true); + (columnEl as any).hidden = null; + expect(column.hidden).toBe(false); + + (columnEl as any).rowStart = '2'; + expect(column.rowStart).toBe(2); + (columnEl as any).rowStart = 'not a number'; + expect(column.rowStart).toBe('not a number'); + }); + + it('should hide opened toggle components in the grid on grid scroll', () => { + const createToggle = () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + return toggle; + }; + const toggle = createToggle(); + const nonToggle = createToggle(); + const detachedToggle = createToggle(); + + const scroll = () => gridEl.dispatchEvent(new CustomEvent('gridScroll')); + const open = (el: HTMLElement, detail = {}) => el.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail })); + + // nothing opened, nothing to hide + scroll(); + + open(toggle); + open(nonToggle, { owner: 'combo' }); + open(detachedToggle); + detachedToggle.remove(); + scroll(); + + expect(toggle.hide).toHaveBeenCalledTimes(1); + expect(nonToggle.hide).not.toHaveBeenCalled(); + expect(detachedToggle.hide).not.toHaveBeenCalled(); + + // opened toggles are cleared after hiding + scroll(); + expect(toggle.hide).toHaveBeenCalledTimes(1); + }); + + it('should stop handling grid popups once the grid is destroyed', async () => { + const toggle = document.createElement('div') as HTMLDivElement & { hide: () => void }; + toggle.hide = vi.fn(); + gridEl.appendChild(toggle); + toggle.dispatchEvent(new CustomEvent('igcOpened', { bubbles: true, detail: {} })); + + gridEl.remove(); + await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + gridEl.dispatchEvent(new CustomEvent('gridScroll')); + + expect(toggle.hide).not.toHaveBeenCalled(); + }); + }); + + describe('Event args component proxies.', () => { + let gridEl: IgcNgElement & InstanceType; + let strategy: any; + let column: IgxColumnComponent; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + strategy = gridEl.ngElementStrategy; + await firstValueFrom(fromEvent(gridEl, 'childrenResolved')); + column = gridEl.columns[0] as IgxColumnComponent; + }); + + it('should wrap components with template props in a proxy that converts templates', () => { + const { value: proxy, hasProxies } = strategy.createProxyForComponentValue(column, 1); + expect(hasProxies).toBe(true); + expect(proxy).not.toBe(column); + + const headerTemplate = () => html`Header`; + proxy.headerTemplate = headerTemplate; + const templateRef = column.headerTemplate; + expect(templateRef).toBeInstanceOf(TemplateRefWrapper); + expect(strategy.templateWrapper.getTemplateFunction(templateRef)).toBe(headerTemplate); + + // same template function keeps the existing ref + proxy.headerTemplate = headerTemplate; + expect(column.headerTemplate).toBe(templateRef); + + // non-template props are set as is + proxy.header = 'Custom header'; + expect(column.header).toBe('Custom header'); + }); + + it('should replace components nested in event args objects with proxies', () => { + const args = { column, owner: null, nested: { column } }; + + const { value, hasProxies } = strategy.createProxyForComponentValue(args, 1); + + expect(hasProxies).toBe(true); + expect(value).toBe(args); + expect(value.owner).toBeNull(); + expect(value.column).not.toBe(column); + expect(value.column.field).toBe(column.field); + expect(value.nested.column).not.toBe(column); + }); + + 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; + const event = new Event('test'); + const deep = { a: { b: { c: { column } } } }; + const plainArray = [{ field: 'ProductID' }]; + + expect(strategy.createProxyForComponentValue(column, 4)).toEqual({ value: column, hasProxies: false }); + expect(strategy.createProxyForComponentValue(event, 1)).toEqual({ value: event, hasProxies: false }); + expect(strategy.createProxyForComponentValue([], 1)).toEqual({ value: [], hasProxies: false }); + expect(strategy.createProxyForComponentValue(plainArray, 1)).toEqual({ value: plainArray, hasProxies: false }); + expect(strategy.createProxyForComponentValue(paginatorInstance, 1)).toEqual({ value: paginatorInstance, hasProxies: false }); + + const { hasProxies } = strategy.createProxyForComponentValue(deep, 1); + expect(hasProxies).toBe(false); + expect(deep.a.b.c.column).toBe(column); + }); + + it('should only patch columnInit 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); + }); + }); + + describe('Hierarchical grid integration.', () => { + let hgridEl: IgcNgElement & InstanceType; + let rowIslandEl: IgcNgElement & InstanceType; + let hgrid: IgxHierarchicalGridComponent; + + const waitFor = async (condition: () => boolean) => { + for (let waited = 0; waited < 3000 && !condition(); waited += 20) { + await firstValueFrom(timer(20)); + } + }; + + beforeEach(async () => { + testContainer.innerHTML = ` + + + + + + `; + hgridEl = testContainer.querySelector('#testHGrid'); + rowIslandEl = testContainer.querySelector('#developers'); + hgrid = (await hgridEl.ngElementStrategy[ComponentRefKey]).instance; + await rowIslandEl.ngElementStrategy[ComponentRefKey]; + hgrid.data = hgridData; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + const expandFirstRow = async () => { + hgrid.expandRow(hgridData[0].ProjectId); + await waitFor(() => hgrid.gridAPI.getChildGrids(false).length > 0); + return hgrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + }; + + it('should re-emit child grid events on the row island with the child grid as owner', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.columns.map(c => c.field)).toContain('DeveloperId'); + + // events without args are re-emitted with the owner only + const rangeSelected = firstValueFrom(fromEvent(rowIslandEl, 'rangeSelected')); + childGrid.rangeSelected.emit(); + expect((await rangeSelected).detail).toEqual({ owner: childGrid }); + + const sortingDone = firstValueFrom(fromEvent(rowIslandEl, 'sortingDone')); + childGrid.sort({ fieldName: 'DeveloperId', dir: SortingDirection.Desc, ignoreCase: false }); + const sortingArgs = (await sortingDone).detail; + expect(sortingArgs.owner).toBe(childGrid); + }); + + it('should emit selectedRowsChange for hierarchical grid', async () => { + const selectionChanged = firstValueFrom(fromEvent(hgridEl, 'selectedRowsChange')); + hgridEl.selectRows([hgridData[0].ProjectId]); + + expect((await selectionChanged).detail).toEqual([hgridData[0].ProjectId]); + }); + + it('should apply row island input changes to existing child grids', async () => { + const childGrid = await expandFirstRow(); + expect(childGrid.allowFiltering).toBe(false); + + rowIslandEl.allowFiltering = true; + await waitFor(() => childGrid.allowFiltering); + + expect(childGrid.allowFiltering).toBe(true); + }); + + it('should render the row island paginator template in child grids and keep it in the root paginator query', async () => { + rowIslandEl.paginatorTemplate = (() => html``) as any; + const childGrid = await expandFirstRow(); + + await waitFor(() => !!childGrid.paginator && hgrid.paginatorList?.length > 0); + + const paginatorEl = childGrid.nativeElement.querySelector('igc-paginator'); + expect(paginatorEl).toBeTruthy(); + const paginator = (await paginatorEl.ngElementStrategy[ComponentRefKey]).instance; + expect(childGrid.paginator).toBe(paginator); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((hgridEl.ngElementStrategy as any).cachedChildComponents.get('paginatorList')).toContain(paginator); + await waitFor(() => childGrid.dataView.length === 2); + expect(childGrid.dataView.length).toBe(2); + + // collapsing detaches the cached child grid from the DOM, the paginator stays registered and alive + hgrid.collapseRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(paginatorEl.isConnected).toBe(false); + expect(hgrid.paginatorList.toArray()).toContain(paginator); + expect((paginatorEl.ngElementStrategy as any).componentRef).toBeTruthy(); + + hgrid.expandRow(hgridData[0].ProjectId); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + expect(childGrid.paginator).toBe(paginator); + }); }); }); diff --git a/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts new file mode 100644 index 00000000000..a93a8c48a0b --- /dev/null +++ b/projects/igniteui-angular-elements/src/app/ng-element-strategy.spec.ts @@ -0,0 +1,187 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgElementStrategyEvent } from '@angular/elements'; +import { firstValueFrom, timer } from 'rxjs'; +import { ComponentNgElementStrategy, ComponentNgElementStrategyFactory, extractProjectableNodes } from './ng-element-strategy'; +import { injector } from '../utils/injector-ref'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +@Component({ + selector: 'igx-strategy-test', + template: `
{{ value }}
`, + standalone: true +}) +class StrategyTestComponent { + @Input() + public value: string; + + @Input('aliasName') + public aliased: string; + + @Output() + public valueChanged = new EventEmitter(); + + public destroyed = false; + + public ngOnDestroy() { + this.destroyed = true; + } +} + +/** DESTROY_DELAY in ng-element-strategy */ +const DESTROY_DELAY = 10; + +describe('ComponentNgElementStrategy', () => { + let host: HTMLElement; + let strategy: ComponentNgElementStrategy; + + const componentRef = () => (strategy as any).componentRef; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + strategy = new ComponentNgElementStrategyFactory(StrategyTestComponent).create(injector); + }); + + afterEach(() => { + strategy.disconnect(); + host.remove(); + }); + + it('should create strategies that map input property names to template names', () => { + const factory = new ComponentNgElementStrategyFactory(StrategyTestComponent); + + expect(factory.create(injector)).toBeInstanceOf(ComponentNgElementStrategy); + expect(factory.inputMap.get('value')).toBe('value'); + expect(factory.inputMap.get('aliased')).toBe('aliasName'); + }); + + it('should cache input values set before connect and apply them on connect', () => { + strategy.setInputValue('value', 'initial'); + strategy.setInputValue('aliased', 'aliased initial'); + + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBe('initial'); + + strategy.connect(host); + + const instance = componentRef().instance as StrategyTestComponent; + expect(instance.value).toBe('initial'); + expect(instance.aliased).toBe('aliased initial'); + expect(host.querySelector('.value').textContent).toBe('initial'); + expect((strategy as any).initialInputValues.size).toBe(0); + }); + + it('should set inputs on the component through their template name after connect', () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + expect(strategy.getInputValue('aliased')).toBe('new value'); + + // setting an unchanged value should leave the view clean + componentRef().hostView.detectChanges(); + strategy.setInputValue('aliased', 'new value'); + expect(instance.aliased).toBe('new value'); + }); + + it('should emit component outputs through the events stream', () => { + const events: NgElementStrategyEvent[] = []; + const sub = strategy.events.subscribe(e => events.push(e)); + strategy.connect(host); + + (componentRef().instance as StrategyTestComponent).valueChanged.emit('changed'); + expect(events).toEqual([{ name: 'valueChanged', value: 'changed' }]); + + sub.unsubscribe(); + (componentRef().instance as StrategyTestComponent).valueChanged.emit('ignored'); + expect(events.length).toBe(1); + }); + + it('should project child nodes into the matching content slots', () => { + const header = document.createElement('header'); + header.textContent = 'Header'; + const span = document.createElement('span'); + span.textContent = 'Content'; + host.appendChild(span); + host.appendChild(header); + host.appendChild(document.createTextNode('Text')); + + strategy.connect(host); + + expect(host.querySelector('.header').contains(header)).toBe(true); + expect(host.querySelector('.rest').contains(span)).toBe(true); + expect(host.querySelector('.rest').textContent).toBe('ContentText'); + }); + + it('should destroy the component with a delay after disconnect', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + // second disconnect while already scheduled is a no-op + strategy.disconnect(); + expect(instance.destroyed).toBe(false); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(true); + expect(componentRef()).toBeNull(); + expect(strategy.getInputValue('value')).toBeUndefined(); + }); + + it('should keep the component when reconnected before the scheduled destroy', async () => { + strategy.connect(host); + const instance = componentRef().instance as StrategyTestComponent; + + strategy.disconnect(); + strategy.connect(host); + + await firstValueFrom(timer(DESTROY_DELAY * 2)); + + expect(instance.destroyed).toBe(false); + expect(componentRef().instance).toBe(instance); + }); + + it('should not schedule destroy when disconnected before connect', async () => { + strategy.disconnect(); + expect((strategy as any).scheduledDestroyFn).toBeNull(); + }); +}); + +describe('extractProjectableNodes', () => { + const createHost = (...children: Node[]) => { + const host = document.createElement('div'); + children.forEach(c => host.appendChild(c)); + return host; + }; + + it('should distribute nodes by selector and default the rest to the wildcard slot', () => { + const header = document.createElement('header'); + const footer = document.createElement('div'); + footer.className = 'footer'; + const other = document.createElement('span'); + const text = document.createTextNode('text'); + const host = createHost(header, other, footer, text); + + const nodes = extractProjectableNodes(host, ['header', '*', '.footer']); + + expect(nodes).toEqual([[header], [other, text], [footer]]); + }); + + it('should drop nodes that match no selector when there is no wildcard slot', () => { + const header = document.createElement('header'); + const other = document.createElement('span'); + const host = createHost(other, document.createTextNode('text'), header); + + const nodes = extractProjectableNodes(host, ['header']); + + expect(nodes).toEqual([[header]]); + }); + + it('should return no slots for components without content projection', () => { + const host = createHost(document.createElement('span')); + + expect(extractProjectableNodes(host, [])).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts index 495a61669d7..5cd4a442289 100644 --- a/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts +++ b/projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { html } from 'lit'; import { AsyncDirective, directive } from 'lit/async-directive.js'; import { TemplateWrapperComponent } from './wrapper.component'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { TemplateRefWrapper } from './template-ref-wrapper'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; const directiveLog = []; class ToLowerAsyncDirective extends AsyncDirective { @@ -64,4 +65,88 @@ describe('WrapperComponent', () => { embeddedView.destroy(); expect(directiveLog).toEqual(['render', 'disconnected']); }); + + it('should expose the context through a proxy with implicit and context props', () => { + let templateContext: any; + const context = { $implicit: 'implicit value', text: 'text value' }; + const templateRef = component.addTemplate((ctx) => { + templateContext = ctx; + return html`${ctx.implicit}`; + }); + const embeddedView = templateRef.createEmbeddedView(context); + embeddedView.detectChanges(); + + expect(templateContext.implicit).toBe('implicit value'); + expect(templateContext.text).toBe('text value'); + expect(templateContext.context).toBe(templateContext); + expect('implicit' in templateContext).toBe(true); + expect('text' in templateContext).toBe(false); + // not bridged, so the bridge props are not handled: + expect(templateContext.___contentId).toBeUndefined(); + expect(templateContext.___immediate).toBeUndefined(); + expect(embeddedView.rootNodes[0].textContent).toBe('implicit value'); + }); + + it('should init and teardown bridged templates with their content context', () => { + let templateContext: any; + const templateFunc: any = (ctx) => { + templateContext = ctx; + return html`Bridged`; + }; + templateFunc.___isBridged = true; + templateFunc.___onTemplateInit = vi.fn(); + templateFunc.___onTemplateTeardown = vi.fn(); + + const templateRef = component.addTemplate(templateFunc); + const embeddedView = templateRef.createEmbeddedView({}); + const root = embeddedView.rootNodes[0]; + + expect(templateFunc.___onTemplateInit).toHaveBeenCalledTimes(1); + const [initFunc, initRoot, contentContext] = templateFunc.___onTemplateInit.mock.calls[0]; + expect(initFunc).toBe(templateFunc); + expect(initRoot).toBe(root); + expect(contentContext.root).toBe(root); + expect(contentContext.templateFunction).toBe(templateFunc); + expect(contentContext._id).toBeTruthy(); + expect(root._id).toBe(contentContext._id); + expect((templateRef as any)._contentContext.get(contentContext._id)).toBe(contentContext); + + embeddedView.detectChanges(); + expect(templateContext.___contentId).toBe(contentContext._id); + expect(templateContext.___immediate).toBe(true); + expect(templateContext.___root).toBe(root); + + embeddedView.destroy(); + expect(templateFunc.___onTemplateTeardown).toHaveBeenCalledExactlyOnceWith(templateFunc, root, contentContext); + expect((templateRef as any)._contentContext.size).toBe(0); + }); + + it('should expose the inner template elementRef and ssrId', () => { + const templateRef = component.addTemplate(() => html``) as TemplateRefWrapper; + + expect(templateRef.elementRef).toBe(templateRef.innerTemplateRef.elementRef); + expect(templateRef.ssrId).toBe((templateRef.innerTemplateRef as any).ssrId); + }); + + it('should resolve the template function from a wrapped template ref', () => { + const first = () => html`1`; + const second = () => html`2`; + const firstRef = component.addTemplate(first) as TemplateRefWrapper; + const secondRef = component.addTemplate(second) as TemplateRefWrapper; + + expect(component.getTemplateFunction(firstRef)).toBe(first); + expect(component.getTemplateFunction(secondRef)).toBe(second); + expect(component.getTemplateFunction(null)).toBeUndefined(); + }); + + it('should emit templateRendered with the template container', () => { + const rendered: HTMLElement[] = []; + component.templateRendered.subscribe(container => rendered.push(container)); + const templateRef = component.addTemplate(() => html`Rendered`); + const embeddedView = templateRef.createEmbeddedView({}); + embeddedView.detectChanges(); + + expect(rendered).toEqual([embeddedView.rootNodes[0]]); + expect(rendered[0].textContent).toBe('Rendered'); + }); }); diff --git a/projects/igniteui-angular-elements/src/lib/exporters.spec.ts b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts new file mode 100644 index 00000000000..3be52ad3579 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/exporters.spec.ts @@ -0,0 +1,63 @@ +import { IgxCsvExporterOptions, IgxCsvExporterService, IgxExcelExporterOptions, IgxExcelExporterService, CsvFileTypes } from 'igniteui-angular'; +import { IgcCsvExporterService } from './csv-exporter'; +import { IgcExcelExporterService } from './excel-exporter'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('Elements exporter services', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const gridInstance = { id: 'grid-instance' }; + const gridElement = { ngElementStrategy: { componentRef: { instance: gridInstance } } }; + + describe('IgcCsvExporterService', () => { + const options = new IgxCsvExporterOptions('Test', CsvFileTypes.CSV); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcCsvExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxCsvExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcCsvExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); + + describe('IgcExcelExporterService', () => { + const options = new IgxExcelExporterOptions('Test'); + + it('should export the Angular grid instance behind a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + + new IgcExcelExporterService().export(gridElement as any, options); + + expect(exportSpy).toHaveBeenCalledTimes(1); + expect(exportSpy).toHaveBeenCalledWith(gridInstance, options); + }); + + it('should export the passed grid as is when it is not a custom element', () => { + const exportSpy = vi.spyOn(IgxExcelExporterService.prototype, 'export').mockImplementation(() => {}); + const uninitializedElement = { ngElementStrategy: { componentRef: null } }; + + const service = new IgcExcelExporterService(); + service.export(gridInstance as any, options); + service.export(uninitializedElement as any, options); + + expect(exportSpy).toHaveBeenNthCalledWith(1, gridInstance, options); + expect(exportSpy).toHaveBeenNthCalledWith(2, uninitializedElement, options); + }); + }); +}); 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 ac9573567d2..fc984090e92 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 @@ -3,7 +3,7 @@ import { ActionType, BroadcastIconsChangeMessage, IgxIconBroadcastService, SvgIc import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { IconMeta, IgxIconService } from 'igniteui-angular'; import { wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('Icon broadcast service', () => { let fixture: ComponentFixture; @@ -102,7 +102,53 @@ describe('Icon broadcast service', () => { expect(serviceRef.family).toBe("customCollection"); expect(serviceRef.name).toBe("customIcon"); }); - }) + + it('should ignore messages without collections or references.', async () => { + const iconService = fixture.componentInstance.iconService; + const addSvgSpy = vi.spyOn(iconService, 'addSvgIconFromText'); + const setRefSpy = vi.spyOn(iconService, 'setIconRef'); + + broadcastChannel.postMessage({ actionType: ActionType.SyncState }); + await wait(50); + + expect(addSvgSpy).not.toHaveBeenCalled(); + expect(setRefSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Page lifecycle', () => { + it('should close the channel on pagehide and re-create it on pageshow.', async () => { + const iconService = fixture.componentInstance.iconService; + await wait(50); + expect(events.length).toBe(1); + + globalThis.dispatchEvent(new Event('pagehide')); + const icon: Map = new Map([["hiddenIcon", { svg: buildIcon }]]); + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["hiddenCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "hiddenCollection")).toBeUndefined(); + + // hiding again with an already closed channel is a no-op + expect(() => globalThis.dispatchEvent(new Event('pagehide'))).not.toThrow(); + + globalThis.dispatchEvent(new Event('pageshow')); + await wait(50); + // re-created channel requests state sync again + // (services from previous tests listen to the global page events as well) + expect(events.length).toBeGreaterThan(1); + expect(events.every(e => e.actionType === ActionType.SyncState)).toBe(true); + + broadcastChannel.postMessage({ + actionType: ActionType.RegisterIcon, + collections: new Map([["shownCollection", icon]]) + }); + await wait(50); + expect(iconService.getSvgIcon("hiddenIcon", "shownCollection")).not.toBeUndefined(); + }); + }); }); @Component({ diff --git a/projects/igniteui-angular-elements/src/lib/state.component.spec.ts b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts new file mode 100644 index 00000000000..c1adcc7cfb9 --- /dev/null +++ b/projects/igniteui-angular-elements/src/lib/state.component.spec.ts @@ -0,0 +1,148 @@ +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; +import { firstValueFrom, timer } from 'rxjs'; +import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; +import { ComponentRefKey, IgcNgElement } from '../app/custom-strategy'; +import { IgcColumnComponent, IgcGridComponent, IgcGridStateComponent } from '../app/components'; +import { defineComponents } from '../utils/register'; +import { IgxGridComponent } from './grids/grid.component'; +import { IGridStateInfo, IgxGridStateComponent } from './state.component'; +import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest'; + +describe('Elements: Grid state component', () => { + let testContainer: HTMLDivElement; + let gridEl: IgcNgElement & InstanceType; + let stateEl: IgcNgElement & InstanceType; + let grid: IgxGridComponent; + let state: IgxGridStateComponent; + + const sortingState = () => [{ fieldName: 'ProductID', dir: SortingDirection.Desc, ignoreCase: false }]; + + beforeAll(() => { + defineComponents(IgcGridComponent, IgcColumnComponent, IgcGridStateComponent); + }); + + beforeEach(async () => { + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + testContainer.innerHTML = ` + + + + + `; + gridEl = testContainer.querySelector('#testGrid') as any; + stateEl = testContainer.querySelector('igc-grid-state') as any; + grid = (await gridEl.ngElementStrategy[ComponentRefKey]).instance; + state = (await stateEl.ngElementStrategy[ComponentRefKey]).instance; + gridEl.data = SampleTestData.foodProductData(); + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + }); + + afterEach(() => { + document.body.removeChild(testContainer); + }); + + it('should be bound to its parent grid', () => { + expect(state.grid).toBe(grid); + }); + + it('should get the state of all features by default', () => { + grid.sort(sortingState()); + + const gridState = stateEl.getState(); + + expect(gridState.sorting).toMatchObject(sortingState()); + expect(gridState.columns.map(c => c.field)).toEqual(['ProductID', 'ProductName']); + expect(gridState.filtering).toBeDefined(); + expect(gridState.rowSelection).toBeDefined(); + }); + + it('should get the state of only the requested features', () => { + grid.sort(sortingState()); + + expect(Object.keys(stateEl.getState('sorting'))).toEqual(['sorting']); + expect(Object.keys(stateEl.getState(['sorting', 'columns'])).sort()).toEqual(['columns', 'sorting']); + + const serialized = stateEl.getStateAsString(['sorting']); + expect(JSON.parse(serialized)).toEqual({ sorting: sortingState() }); + expect(Object.keys(JSON.parse(stateEl.getStateAsString())).length).toBeGreaterThan(1); + }); + + it('should apply state of all or only of the requested features', () => { + const gridState: IGridStateInfo = { + sorting: sortingState(), + moving: false + }; + + stateEl.applyState(gridState, ['moving']); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(false); + + stateEl.applyState(gridState); + expect(grid.sortingExpressions).toMatchObject(sortingState()); + expect(grid.dataView[0].ProductID).toBe(Math.max(...gridEl.data.map(x => x.ProductID))); + }); + + it('should emit stateParsed when applying state from string and apply modifications to the parsed state', async () => { + const parsedStates: IGridStateInfo[] = []; + stateEl.addEventListener('stateParsed', (e: CustomEvent) => { + parsedStates.push(structuredClone(e.detail)); + e.detail.sorting[0].dir = SortingDirection.Asc; + }); + + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState() })); + + expect(parsedStates).toEqual([{ sorting: sortingState() }]); + expect(grid.sortingExpressions[0].dir).toBe(SortingDirection.Asc); + + grid.clearSort(); + stateEl.applyStateFromString(JSON.stringify({ sorting: sortingState(), moving: true }), 'moving'); + expect(grid.sortingExpressions).toEqual([]); + expect(grid.moving).toBe(true); + }); + + it('should serialize Set search values of filtering expressions as arrays', () => { + const condition = IgxStringFilteringOperand.instance().condition('in'); + grid.filter('ProductName', new Set(['Chai', 'Chang']), condition); + + const serialized = JSON.parse(stateEl.getStateAsString(['filtering'])); + const expression = serialized.filtering.filteringOperands[0].filteringOperands[0]; + + expect(expression.searchVal).toEqual(['Chai', 'Chang']); + }); + + it('should omit externalObject values when serializing', () => { + const callback = (key: string, value: any) => (state as any).stringifyCallback(key, value); + const set = new Set([1, 2]); + + expect(callback('externalObject', { some: 'value' })).toBeUndefined(); + expect(callback('searchVal', set)).toEqual([1, 2]); + expect(callback('searchVal', 'text')).toBe('text'); + expect(callback('other', set)).toBe(set); + }); + + it('should apply the state through the element API from a grid in another container', async () => { + const otherContainer = document.createElement('div'); + document.body.appendChild(otherContainer); + try { + otherContainer.innerHTML = ` + + + + `; + const otherGridEl = otherContainer.querySelector('igc-grid'); + const otherStateEl = otherContainer.querySelector>('igc-grid-state'); + const otherGrid = (await otherGridEl.ngElementStrategy[ComponentRefKey]).instance as IgxGridComponent; + await otherStateEl.ngElementStrategy[ComponentRefKey]; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 4)); + + grid.sort(sortingState()); + otherStateEl.applyStateFromString(stateEl.getStateAsString(['sorting'])); + + expect(otherGrid.sortingExpressions).toMatchObject(sortingState()); + } finally { + otherContainer.remove(); + } + }); +}); diff --git a/projects/igniteui-angular-elements/src/utils/register.spec.ts b/projects/igniteui-angular-elements/src/utils/register.spec.ts new file mode 100644 index 00000000000..8c0c62f3c56 --- /dev/null +++ b/projects/igniteui-angular-elements/src/utils/register.spec.ts @@ -0,0 +1,82 @@ +import { defineComponents, IgniteComponent, registerComponent } from './register'; +import { defineAllComponents } from './define-all'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +let uniqueId = 0; +const createComponent = (): IgniteComponent => { + const ctor = class extends HTMLElement { } as unknown as IgniteComponent; + ctor.tagName = `igc-register-test-${uniqueId++}`; + ctor.register = vi.fn(() => registerComponent(ctor)); + return ctor; +}; + +describe('Elements register utils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerComponent should define the component and register its dependencies first', () => { + const dependency = createComponent(); + const component = createComponent(); + const defineSpy = vi.spyOn(customElements, 'define'); + + registerComponent(component, dependency); + + expect(dependency.register).toHaveBeenCalledTimes(1); + expect(customElements.get(dependency.tagName)).toBe(dependency); + expect(customElements.get(component.tagName)).toBe(component); + expect(defineSpy.mock.calls.map(([tag]) => tag)).toEqual([dependency.tagName, component.tagName]); + }); + + it('registerComponent should not re-define an already defined component', () => { + const component = createComponent(); + registerComponent(component); + const defineSpy = vi.spyOn(customElements, 'define'); + + expect(() => registerComponent(component)).not.toThrow(); + expect(defineSpy).not.toHaveBeenCalled(); + }); + + it('defineComponents should call register on every component', () => { + const components = [createComponent(), createComponent()]; + + defineComponents(...components); + + components.forEach(c => { + expect(c.register).toHaveBeenCalledTimes(1); + expect(customElements.get(c.tagName)).toBe(c); + }); + }); + + it('defineAllComponents should define all grid related elements', () => { + defineAllComponents(); + + const tags = [ + 'igc-grid', + 'igc-tree-grid', + 'igc-hierarchical-grid', + 'igc-pivot-grid', + 'igc-pivot-data-selector', + 'igc-row-island', + 'igc-column-group', + 'igc-column-layout', + 'igc-column', + 'igc-paginator', + 'igc-grid-toolbar', + 'igc-action-strip', + 'igc-grid-state', + 'igc-grid-editing-actions', + 'igc-grid-pinning-actions', + 'igc-grid-toolbar-title', + 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-hiding', + 'igc-grid-toolbar-pinning', + 'igc-grid-toolbar-exporter', + 'igc-grid-toolbar-advanced-filtering', + 'igc-query-builder', + 'igc-query-builder-header' + ]; + const undefinedTags = tags.filter(tag => !customElements.get(tag)); + expect(undefinedTags).toEqual([]); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts index 5f5f396f5f7..cb2123bb691 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/chart-dialog/chart-dialog.component.spec.ts @@ -1,7 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ElementRef } from '@angular/core'; import { IgxChartMenuComponent } from './chart-dialog.component'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartMenuComponent', () => { let component: IgxChartMenuComponent; @@ -234,3 +234,110 @@ describe('IgxChartMenuComponent', () => { }); }); }); + +describe('IgxChartMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxChartMenuComponent; + let chartDirective: { chartFactory: ReturnType }; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + const headerButtons = () => queryAll('.action-buttons-wrapper button'); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxChartMenuComponent] }); + fixture = TestBed.createComponent(IgxChartMenuComponent); + component = fixture.componentInstance; + chartDirective = { chartFactory: vi.fn() }; + component.chartDirective = chartDirective; + component.allCharts = ['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']; + component.width = 1000; + component.height = 500; + fixture.detectChanges(); + component.createChart('ColumnStacked'); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create the chart in the chart area and show its title in the header', () => { + expect(chartDirective.chartFactory).toHaveBeenCalledWith('ColumnStacked', component.chartArea); + expect(query('.header-text').textContent!.trim()).toBe('Column Stacked Chart'); + }); + + it('should bind the host size to 70% of the given dimensions and to 100% in full screen', () => { + const host: HTMLElement = fixture.nativeElement; + expect(host.style.width).toBe('700px'); + expect(host.style.height).toBe('350px'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(true); + expect(host.style.width).toBe('1000px'); + expect(host.style.height).toBe('500px'); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen_exit'); + + headerButtons()[0].click(); + fixture.detectChanges(); + + expect(component.fullScreen).toBe(false); + expect(headerButtons()[0].textContent!.trim()).toBe('fullscreen'); + }); + + it('should emit closed when the close button is clicked', () => { + const closedSpy = vi.fn(); + component.closed.subscribe(closedSpy); + + headerButtons()[1].click(); + + expect(closedSpy).toHaveBeenCalledTimes(1); + }); + + it('should toggle the chart config area with the chevron button', () => { + const configArea = query('.chart-config-area'); + const toggleButton = query('.selection-area > div > button'); + expect(configArea.classList).toContain('opened'); + expect(query('.chart-area').classList).not.toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_right'); + + toggleButton.click(); + fixture.detectChanges(); + + expect(component.isConfigAreaExpanded).toBe(true); + expect(configArea.classList).toContain('closed'); + expect(configArea.classList).not.toContain('opened'); + expect(query('.chart-area').classList).toContain('expanded'); + expect(toggleButton.textContent!.trim()).toBe('chevron_left'); + }); + + it('should render a section only for the main chart types that have available charts', () => { + const sections = queryAll('.wrapper') + .filter(w => w.querySelector('.types-section')) + .map(w => w.firstElementChild!.textContent!.trim()); + + expect(sections).toEqual(['Column Chart', 'Bar Chart', 'Pie Chart']); + }); + + it('should render a button per available chart and mark the current one as selected', () => { + const buttons = queryAll('.types-section-btn'); + + expect(buttons.map(b => b.title)).toEqual(['ColumnGrouped', 'ColumnStacked', 'BarGrouped', 'Pie']); + expect(buttons[1].classList).toContain('selected'); + expect(buttons[0].classList).not.toContain('selected'); + }); + + it('should create the chosen chart when a chart type button is clicked', () => { + const pieButton = queryAll('.types-section-btn').find(b => b.title === 'Pie')!; + + pieButton.click(); + fixture.detectChanges(); + + expect(chartDirective.chartFactory).toHaveBeenLastCalledWith('Pie', component.chartArea); + expect(component.currentChartType).toBe('Pie'); + expect(pieButton.classList).toContain('selected'); + expect(query('.header-text').textContent!.trim()).toBe('Pie Chart'); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts index ab82c899fbe..9c5662db7f9 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/context-menu.component.spec.ts @@ -1,6 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EventEmitter } from '@angular/core'; import { Subject } from 'rxjs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxOverlayService } from 'igniteui-angular/core'; import { IgxContextMenuComponent } from './context-menu.component'; import { CHART_TYPE } from '../directives/chart-integration/chart-types'; @@ -305,11 +306,104 @@ describe('IgxContextMenuComponent', () => { }); describe('overlayService.opening with chartDialogResizeNotify', () => { + let overlayElement: HTMLElement; + let resizeNotify: Subject; + + const createWrapper = (classes: string[], hidden = false) => { + const wrapper = document.createElement('div'); + wrapper.classList.add(...classes); + if (hidden) { + wrapper.style.visibility = 'hidden'; + } + const content = document.createElement('div'); + wrapper.appendChild(content); + overlayElement.appendChild(wrapper); + return content; + }; + + const openDialogWithResizeNotify = () => { + resizeNotify = new Subject(); + const mockInstance = { chartDialogResizeNotify: resizeNotify, closed: undefined } as any; + (mockOverlay.opening as Subject).next({ id: 'dialog-id', componentRef: { instance: mockInstance } }); + }; + + beforeEach(() => { + overlayElement = document.createElement('div'); + document.body.appendChild(overlayElement); + }); + + afterEach(() => { + overlayElement.remove(); + }); + it('should not throw when opening event has no componentRef', () => { expect(() => { (mockOverlay.opening as Subject).next({ componentRef: undefined }); }).not.toThrow(); }); + + it('should resize the content of the visible flex wrapper to the new dialog width', () => { + const hiddenContent = createWrapper(['igx-overlay__wrapper--flex'], true); + const visibleContent = createWrapper(['igx-overlay__wrapper--flex']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(mockOverlay.getOverlayById).toHaveBeenCalledWith('dialog-id'); + expect(visibleContent.style.width).toBe('420px'); + expect(hiddenContent.style.width).toBe(''); + }); + + it('should ignore wrappers that are not flex overlay wrappers', () => { + const content = createWrapper(['igx-overlay__wrapper']); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + resizeNotify.next([{ contentRect: { width: 420 } }]); + + expect(content.style.width).toBe(''); + }); + + it('should not throw when the flex wrapper has no content element', () => { + const wrapper = document.createElement('div'); + wrapper.classList.add('igx-overlay__wrapper--flex'); + overlayElement.appendChild(wrapper); + mockOverlay.getOverlayById.mockReturnValue({ elementRef: { nativeElement: overlayElement } }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + + it('should not throw when the overlay has no element', () => { + mockOverlay.getOverlayById.mockReturnValue({ elementRef: undefined }); + openDialogWithResizeNotify(); + + expect(() => resizeNotify.next([{ contentRect: { width: 420 } }])).not.toThrow(); + }); + }); + + describe('overlayService.closing subscription without an open dialog', () => { + it('should not reopen tabsMenu when the chart dialog closes and no dialog id is tracked', () => { + (mockOverlay.closing as Subject).next({ + componentRef: { instance: TestBed.createComponent(IgxChartMenuComponent).componentInstance } + }); + + expect(mockTabsMenu.open).not.toHaveBeenCalled(); + }); + }); + + describe('without optional chart and formatting directives', () => { + it('should initialize from the context directive without subscribing to missing directives', () => { + mockContextDirective.chartsDirective = null; + mockContextDirective.textFormatter = null; + mockContextDirective.charts = []; + mockContextDirective.formatters = []; + + expect(() => component.ngAfterViewInit()).not.toThrow(); + expect(component.chartTypes).toEqual([]); + expect(component.textFormatters).toEqual([]); + }); }); describe('constructor', () => { @@ -332,3 +426,231 @@ describe('IgxContextMenuComponent', () => { }); }); }); + +describe('IgxContextMenuComponent rendering', () => { + let fixture: ComponentFixture; + let component: IgxContextMenuComponent; + let overlay: IgxOverlayService; + let contextDirective: any; + + const query = (selector: string): HTMLElement => fixture.nativeElement.querySelector(selector); + const queryAll = (selector: string): HTMLElement[] => Array.from(fixture.nativeElement.querySelectorAll(selector)); + + const createComponent = async (overrides: any = {}) => { + contextDirective = { + charts: [CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped, CHART_TYPE.Pie], + formatters: ['Top 10', 'Duplicate Values'], + displayCreationTab: true, + chartsDirective: { + chartTypesDetermined: new EventEmitter(), + chartFactory: vi.fn() + }, + textFormatter: { + formattersReady: new EventEmitter(), + formatCells: vi.fn(), + clearFormatting: vi.fn(), + formatter: undefined + }, + buttonClose: new EventEmitter(), + gridResizeNotify: new Subject(), + grid: { nativeElement: document.createElement('div') }, + ...overrides + }; + fixture = TestBed.createComponent(IgxContextMenuComponent); + component = fixture.componentInstance; + component.contextDirective = contextDirective; + // ngAfterViewInit copies these from the context directive after the view is checked; + // seed the same values up front so the first check is stable in dev mode + component.chartTypes = contextDirective.charts; + component.textFormatters = contextDirective.formatters; + component.displayCreationTab = contextDirective.displayCreationTab; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + const selectChartsTab = async () => { + component.tabs.selectedIndex = 1; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [IgxContextMenuComponent, NoopAnimationsModule] }); + overlay = TestBed.inject(IgxOverlayService); + }); + + afterEach(() => { + component?.closeDialog(); + TestBed.resetTestingModule(); + }); + + it('should toggle the tabs menu when the analytics button is clicked', async () => { + await createComponent(); + expect(component.tabsMenu.collapsed).toBe(true); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(false); + expect(component.currentChartType).toBe(CHART_TYPE.ColumnGrouped); + + query('.analytics-button').click(); + fixture.detectChanges(); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should close an open tabs menu when the grid is resized', async () => { + await createComponent(); + component.toggleTabMenu(); + expect(component.tabsMenu.collapsed).toBe(false); + + contextDirective.gridResizeNotify.next(); + + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should not close the tabs menu on grid resize when it is already closed', async () => { + await createComponent(); + const closeSpy = vi.spyOn(component.tabsMenu, 'close'); + + contextDirective.gridResizeNotify.next(); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should render a formatting option per formatter and apply it on click', async () => { + await createComponent(); + const conditions = queryAll('.condition'); + + expect(conditions.length).toBe(2); + expect(conditions[0].querySelector('.name')!.textContent!.trim()).toBe('Top 10%'); + expect(conditions[1].querySelector('.name')!.textContent!.trim()).toBe('Duplicate Values'); + + conditions[1].click(); + fixture.detectChanges(); + + expect(contextDirective.textFormatter.formatCells).toHaveBeenCalledWith('Duplicate Values'); + expect(conditions[1].classList).toContain('selected--condition'); + expect(conditions[0].classList).not.toContain('selected--condition'); + }); + + it('should disable "Clear All" until a formatter is applied and clear formatting on click', async () => { + await createComponent(); + const clear = query('.clear'); + expect(clear.classList).toContain('disableButton'); + + contextDirective.textFormatter.formatter = 'Top 10'; + fixture.detectChanges(); + expect(clear.classList).not.toContain('disableButton'); + + component.currentFormatter = 'Top 10'; + clear.click(); + + expect(contextDirective.textFormatter.clearFormatting).toHaveBeenCalled(); + expect(component.currentFormatter).toBeUndefined(); + }); + + it('should disable "Clear All" when there is no conditional formatting directive', async () => { + await createComponent({ textFormatter: null }); + + expect(query('.clear').classList).toContain('disableButton'); + }); + + it('should render at most four chart options and mark the current chart as selected', async () => { + await createComponent(); + component.currentChartType = CHART_TYPE.BarGrouped; + await selectChartsTab(); + + const charts = queryAll('.action.chart'); + expect(charts.length).toBe(4); + expect(charts.map(c => c.querySelector('.name')!.textContent!.trim())) + .toEqual([CHART_TYPE.ColumnGrouped, CHART_TYPE.BarGrouped, CHART_TYPE.LineGrouped, CHART_TYPE.AreaGrouped]); + expect(charts[1].classList).toContain('selected'); + expect(charts[0].classList).not.toContain('selected'); + expect(query('.more-action').classList).not.toContain('disableButton'); + }); + + it('should preview a chart on mouseenter and hide the preview on mouseleave', async () => { + await createComponent(); + component.toggleTabMenu(); + await selectChartsTab(); + const chart = queryAll('.action.chart')[2]; + + chart.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + + expect(component.currentChartType).toBe(CHART_TYPE.LineGrouped); + expect(contextDirective.chartsDirective.chartFactory).toHaveBeenCalledWith(CHART_TYPE.LineGrouped, component.chartPreview); + expect(component.chartPreviewDialog.collapsed).toBe(false); + + chart.dispatchEvent(new MouseEvent('mouseleave')); + fixture.detectChanges(); + + expect(component.chartPreviewDialog.collapsed).toBe(true); + }); + + it('should open the chart dialog configured from the context when a chart option is clicked', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + component.toggleTabMenu(); + await selectChartsTab(); + + queryAll('.action.chart')[1].click(); + fixture.detectChanges(); + + // the first opening is the tabs menu toggle, which has no componentRef + const dialogArgs = openingSpy.mock.calls.map(c => c[0]).find(args => args.componentRef); + const dialog = dialogArgs.componentRef.instance as IgxChartMenuComponent; + expect(dialog).toBeInstanceOf(IgxChartMenuComponent); + expect(dialog.currentChartType).toBe(CHART_TYPE.BarGrouped); + expect(dialog.allCharts).toBe(component.chartTypes); + expect(dialog.chartDirective).toBe(contextDirective.chartsDirective); + expect(component.tabsMenu.collapsed).toBe(true); + }); + + it('should open the dialog with the current chart type from "More..." and close it via the dialog close event', async () => { + await createComponent(); + const openingSpy = vi.fn(); + overlay.opening.subscribe(openingSpy); + await selectChartsTab(); + component.currentChartType = CHART_TYPE.Pie; + + query('.more-action').click(); + fixture.detectChanges(); + + const dialogInfo = openingSpy.mock.calls[0][0]; + const dialog = dialogInfo.componentRef.instance as IgxChartMenuComponent; + expect(dialog.currentChartType).toBe(CHART_TYPE.Pie); + expect(overlay.getOverlayById(dialogInfo.id)).toBeDefined(); + + dialog.closed.emit(); + + expect(overlay.getOverlayById(dialogInfo.id)).toBeFalsy(); + }); + + it('should disable the chart creation tab when there are no charts', async () => { + await createComponent({ charts: [] }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(2); + expect(headers[0].getAttribute('aria-disabled')).toBe('false'); + expect(headers[1].getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not render the chart creation tab when displayCreationTab is false', async () => { + await createComponent({ displayCreationTab: false }); + + const headers = queryAll('igx-tab-header'); + expect(headers.length).toBe(1); + expect(headers[0].textContent!.trim()).toBe('Conditional Formatting'); + }); + + it('should select the chart creation tab when there are charts but no formatters', async () => { + await createComponent({ formatters: [] }); + + expect(component.tabs.selectedIndex).toBe(1); + expect(queryAll('.action.chart').length).toBe(4); + }); +}); diff --git a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts index b13a0a2f872..886f12973fd 100644 --- a/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/context-menu/igx-context-menu.directive.spec.ts @@ -633,4 +633,120 @@ describe('IgxContextMenuDirective', () => { expect(mockOverlay.hide).toHaveBeenCalledWith('some-id'); }); }); + + describe('grid resize observer', () => { + it('should observe the grid element and forward resize notifications to gridResizeNotify', () => { + let observerCallback: () => void; + const observe = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { + observerCallback = cb; + } + public observe = observe; + public disconnect = vi.fn(); + }); + directive.ngOnDestroy(); + const fresh = TestBed.runInInjectionContext(() => new IgxContextMenuDirective()); + fresh.ngOnInit(); + fresh.ngAfterViewInit(); + const resizeSpy = vi.fn(); + fresh.gridResizeNotify.subscribe(resizeSpy); + + observerCallback!(); + + expect(observe).toHaveBeenCalledWith(mockGrid.nativeElement); + expect(resizeSpy).toHaveBeenCalledTimes(1); + fresh.ngOnDestroy(); + vi.unstubAllGlobals(); + }); + }); + + describe('column selection while the button is visible', () => { + const headerElement = document.createElement('div'); + + beforeEach(() => { + mockGrid.selectedColumns.mockReturnValue([ + { visibleIndex: 0, headerCell: { nativeElement: headerElement } } + ]); + (directive as any)._collapsed = false; + (directive as any)._id = 'some-id'; + }); + + it('should emit buttonClose without hiding when the selection change has no old selection', () => { + const closeSpy = vi.fn(); + directive.buttonClose.subscribe(closeSpy); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(mockOverlay.hide).not.toHaveBeenCalled(); + }); + + it('should reposition the existing overlay at the header cell and update its position strategy', () => { + const info = { settings: { positionStrategy: undefined } }; + mockOverlay.getOverlayById.mockReturnValue(info); + + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'] }); + vi.advanceTimersByTime(100); + + expect(mockOverlay.reposition).toHaveBeenCalledWith('some-id'); + expect(mockOverlay.show).not.toHaveBeenCalled(); + expect(info.settings.positionStrategy).toBe((directive as any)._analyticsBtnSettings.positionStrategy); + expect((directive as any)._analyticsBtnSettings.target).toBe(headerElement); + }); + }); + + describe('without optional chart and formatting directives', () => { + let bare: IgxContextMenuDirective; + + beforeEach(() => { + // detach the default directive so only the bare instance reacts to grid events + directive.ngOnDestroy(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IgxContextMenuDirective, + { provide: IgxGridComponent, useValue: mockGrid }, + { provide: IgxOverlayService, useValue: mockOverlay } + ] + }); + bare = TestBed.inject(IgxContextMenuDirective); + bare.ngOnInit(); + bare.ngAfterViewInit(); + }); + + afterEach(() => { + bare.ngOnDestroy(); + }); + + it('should have null textFormatter and chartsDirective', () => { + expect(bare.textFormatter).toBeNull(); + expect(bare.chartsDirective).toBeNull(); + expect(bare.formatters).toEqual([]); + expect(bare.charts).toEqual([]); + }); + + it('should still handle range selection and show the button', () => { + mockGrid.gridAPI.get_cell_by_index.mockReturnValue({ + row: { key: 0 }, + column: { index: 1 }, + nativeElement: document.createElement('div') + }); + + mockGrid.rangeSelected.next({ rowStart: 0, rowEnd: 1, columnStart: 0, columnEnd: 0 }); + vi.advanceTimersByTime(200); + + expect(mockGrid.getSelectedData).not.toHaveBeenCalled(); + expect(mockOverlay.show).toHaveBeenCalledWith('overlay-id'); + }); + + it('should still clear cell selection on column selection', () => { + mockGrid.columnSelectionChanging.next({ newSelection: ['col1'], oldSelection: [] }); + vi.advanceTimersByTime(100); + + expect(mockGrid.clearCellSelection).toHaveBeenCalled(); + expect(mockGrid.getSelectedColumnsData).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts index c1dfc9dcd98..f4ecadfd707 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/chart-integration.directive.spec.ts @@ -1,6 +1,14 @@ +import { Component, ViewChild, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxColumnSeriesComponent, IgxDataChartCategoryModule, IgxDataChartComponent, + IgxDataChartCoreModule, IgxDataChartScatterModule, IgxDataChartStackedModule, IgxItemLegendComponent, + IgxItemLegendModule, IgxLegendComponent, IgxLegendModule, IgxNumericXAxisModule, IgxNumericYAxisModule, + IgxPieChartComponent, IgxPieChartModule, IgxStacked100BarSeriesComponent, IgxStackedColumnSeriesComponent +} from 'igniteui-angular-charts'; import { CHART_TYPE, OPTIONS_TYPE } from './chart-types'; import { IgxChartIntegrationDirective, IDeterminedChartTypesArgs } from './chart-integration.directive'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('IgxChartIntegrationDirective', () => { let directive: IgxChartIntegrationDirective; @@ -442,4 +450,111 @@ describe('IgxChartIntegrationDirective', () => { expect(result).toBeDefined(); }); }); + + describe('chartFactory with a ViewContainerRef', () => { + @Component({ + template: ``, + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxDataChartScatterModule, IgxPieChartModule, IgxLegendModule, IgxItemLegendModule, + IgxNumericXAxisModule, IgxNumericYAxisModule, IgxCategoryXAxisModule, IgxCategoryYAxisModule + ] + }) + class ChartHostComponent { + @ViewChild('host', { read: ViewContainerRef, static: true }) + public host!: ViewContainerRef; + } + + let fixture: ComponentFixture; + let vcr: ViewContainerRef; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChartHostComponent] }); + fixture = TestBed.createComponent(ChartHostComponent); + fixture.detectChanges(); + vcr = fixture.componentInstance.host; + directive.chartData = [{ name: 'A', q1: 10, q2: 5 }, { name: 'B', q1: 20, q2: 15 }]; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should create a pie chart with an item legend', () => { + let emitted: any; + directive.chartCreationDone.subscribe(c => emitted = c); + + const chart = directive.chartFactory(CHART_TYPE.Pie, vcr); + + expect(chart).toBeInstanceOf(IgxPieChartComponent); + expect(emitted).toBe(chart); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxItemLegendComponent); + expect(chart.valueMemberPath).toBe('q1'); + expect(chart.labelMemberPath).toBe('name'); + }); + + it('should create a data chart with one series per numeric column and a legend', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(2); + expect(chart.legend).toBeInstanceOf(IgxLegendComponent); + expect(chart.series.count).toBe(2); + expect(chart.series.item(0)).toBeInstanceOf(IgxColumnSeriesComponent); + expect(chart.series.item(1).valueMemberPath).toBe('q2'); + expect(chart.axes.count).toBe(2); + }); + + it('should not create a legend when useLegend is false', () => { + directive.useLegend = false; + + const chart = directive.chartFactory(CHART_TYPE.LineGrouped, vcr); + + expect(chart).toBeInstanceOf(IgxDataChartComponent); + expect(vcr.length).toBe(1); + expect(chart.legend).toBeFalsy(); + }); + + it('should create one stacked series with a fragment per numeric column', () => { + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('q1'); + expect(series.series.item(0).title).toBe('q1'); + expect(series.series.item(1).valueMemberPath).toBe('q2'); + }); + + it('should not apply the series model to stacked fragments', () => { + directive.setChartComponentOptions(CHART_TYPE.ColumnStacked, OPTIONS_TYPE.Series, { title: 'custom' }); + + const chart = directive.chartFactory(CHART_TYPE.ColumnStacked, vcr); + + const fragment = chart.series.item(0).series.item(0); + expect(fragment.title).toBe('q1'); + }); + + it('should use the label member path on the y axis for stacked bar charts', () => { + const chart = directive.chartFactory(CHART_TYPE.Bar100Stacked, vcr); + + const series = chart.series.item(0); + expect(series).toBeInstanceOf(IgxStacked100BarSeriesComponent); + expect(series.yAxis.label).toBe('name'); + expect(series.xAxis.label).toBeFalsy(); + }); + + it('should configure the bubble size scale when creating in a container', () => { + directive.chartData = [{ name: 'A', x: 1, y: 2, r: 3 }, { name: 'B', x: 4, y: 5, r: 6 }]; + + const chart = directive.chartFactory(CHART_TYPE.ScatterBubble, vcr); + + const series = chart.series.item(0); + expect(series.radiusMemberPath).toBe('y'); + expect(series.radiusScale.minimumValue).toBe(10); + expect(series.radiusScale.maximumValue).toBe(60); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts index 48681021e4e..2acb949af6a 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/chart-integration/initializers.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { IgxPieChartInitializer, IgxDataChartInitializer, @@ -13,9 +14,17 @@ import { IgxScatterLineSeriesComponent, IgxStackedColumnSeriesComponent, IgxStackedBarSeriesComponent, - IgxStacked100BarSeriesComponent + IgxStacked100BarSeriesComponent, + IgxDataChartComponent, + IgxDataChartCoreModule, + IgxDataChartCategoryModule, + IgxDataChartStackedModule, + IgxCategoryXAxisModule, + IgxCategoryYAxisModule, + IgxNumericXAxisModule, + IgxNumericYAxisModule } from 'igniteui-angular-charts'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Helper to create a mock chart with series and axes arrays function createMockDataChart() { @@ -220,4 +229,82 @@ describe('IgxStackedDataChartInitializer', () => { const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); expect(init.seriesType).toBe(IgxStackedBarSeriesComponent); }); + + describe('initChart', () => { + const stackedOptions = (): IChartComponentOptions => ({ + chartOptions: { width: '100%' }, + stackedFragmentOptions: [ + { valueMemberPath: 'val1', title: 'V1' }, + { valueMemberPath: 'val2', title: 'V2' } + ] as any, + xAxisOptions: { label: 'name' }, + yAxisOptions: { labelExtent: 40 } + }); + + let chart: IgxDataChartComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + IgxDataChartCoreModule, IgxDataChartCategoryModule, IgxDataChartStackedModule, + IgxCategoryXAxisModule, IgxCategoryYAxisModule, IgxNumericXAxisModule, IgxNumericYAxisModule + ] + }); + chart = TestBed.createComponent(IgxDataChartComponent).componentInstance; + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should add a single stacked series with one fragment per fragment option', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series).toBeInstanceOf(IgxStackedColumnSeriesComponent); + expect(series.series.count).toBe(2); + expect(series.series.item(0).valueMemberPath).toBe('val1'); + expect(series.series.item(0).title).toBe('V1'); + expect(series.series.item(1).valueMemberPath).toBe('val2'); + }); + + it('should bind the series to the initializer axes and add them to the chart', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent); + + init.initChart(chart, stackedOptions()); + + const series = chart.series.item(0) as IgxStackedColumnSeriesComponent; + expect(series.xAxis).toBe((init as any).xAxis); + expect(series.yAxis).toBe((init as any).yAxis); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + expect(chart.axes.item(1)).toBe((init as any).yAxis); + expect((init as any).xAxis.label).toBe('name'); + expect((init as any).yAxis.labelExtent).toBe(40); + }); + + it('should apply chart options and return the same chart instance', () => { + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + const result = init.initChart(chart, stackedOptions()); + + expect(result).toBe(chart); + expect(chart.width).toBe('100%'); + }); + + it('should replace existing series and axes when re-initializing a chart', () => { + new IgxStackedDataChartInitializer(IgxStackedColumnSeriesComponent).initChart(chart, stackedOptions()); + const init = new IgxStackedDataChartInitializer(IgxStackedBarSeriesComponent); + + init.initChart(chart, stackedOptions()); + + expect(chart.series.count).toBe(1); + expect(chart.series.item(0)).toBeInstanceOf(IgxStackedBarSeriesComponent); + expect(chart.axes.count).toBe(2); + expect(chart.axes.item(0)).toBe((init as any).xAxis); + }); + }); }); diff --git a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts index 60d9009d267..c7775106c04 100644 --- a/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts +++ b/projects/igniteui-angular-extras/src/lib/directives/conditional-formatting/conditional-formatting.directive.spec.ts @@ -648,4 +648,78 @@ describe('IgxConditionalFormattingDirective', () => { expect(directive.formatColors.text).toBe('#FFF'); }); }); + + describe('duplicates and uniques with text values and out-of-range cells', () => { + beforeEach(() => { + (directive as any).formatedRange = new Map([[0, new Set([0])]]); + }); + + it('should return undefined color for duplicates when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 10 }]); + expect(directive.duplicates.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should return undefined color for uniques when cell is outside the formatted range', () => { + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }]); + expect(directive.uniques.color(null, 0, 10, 5)).toBeUndefined(); + }); + + it('should compare text values only against text data for duplicates', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.duplicates.backgroundColor(null, 0, 'A', 0)).toBe(directive.formatColors.info); + expect(directive.duplicates.color(null, 0, 'B', 0)).toBe(''); + }); + + it('should compare text values only against text data for uniques', () => { + mockGrid.getSelectedData.mockReturnValue([{ name: 'A', val: 1 }, { name: 'A', val: 2 }, { name: 'B', val: 1 }]); + expect(directive.uniques.backgroundColor(null, 0, 'B', 0)).toBe(directive.formatColors.info); + expect(directive.uniques.color(null, 0, 'A', 0)).toBe(''); + }); + }); + + describe('formatCells range handling', () => { + let col0: any, col1: any, col2: any; + + beforeEach(() => { + col0 = { visibleIndex: 0, cellStyles: undefined }; + col1 = { visibleIndex: 1, cellStyles: undefined }; + col2 = { visibleIndex: 2, cellStyles: undefined }; + mockGrid.visibleColumns = [col0, col1, col2]; + mockGrid.getSelectedData.mockReturnValue([{ val: 10 }, { val: 20 }]); + mockGrid.data = [{ val: 10 }, { val: 20 }]; + }); + + it('should use the provided formatRange instead of the grid selected ranges', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + const customRange: any = [{ rowStart: 1, rowEnd: 1, columnStart: 2, columnEnd: 2 }]; + + directive.formatCells(ConditionalFormattingType.ColorScale, customRange); + + expect(directive.isWithInFormattedRange(1, 2)).toBe(true); + expect(directive.isWithInFormattedRange(0, 0)).toBe(false); + }); + + it('should keep the existing formatted range when reset is false', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }]); + directive.formatCells(ConditionalFormattingType.ColorScale); + + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 1, rowEnd: 1, columnStart: 1, columnEnd: 1 }]); + directive.formatCells(ConditionalFormattingType.Empty, undefined, false); + + expect(directive.isWithInFormattedRange(0, 0)).toBe(true); + expect(directive.isWithInFormattedRange(1, 1)).toBe(false); + expect(directive.formatter).toBe(ConditionalFormattingType.Empty); + }); + + it('should not notify the grid when no visible column falls into the range', () => { + mockGrid.getSelectedRanges.mockReturnValue([{ rowStart: 0, rowEnd: 1, columnStart: 5, columnEnd: 6 }]); + + directive.formatCells(ConditionalFormattingType.ColorScale); + + expect(mockGrid.notifyChanges).not.toHaveBeenCalled(); + expect(col0.cellStyles).toBeNull(); + expect(col1.cellStyles).toBeNull(); + expect(col2.cellStyles).toBeNull(); + }); + }); }); From e3f42baceffc6fc4b6d17f8cd4a38b73f02299c6 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Tue, 20 Oct 2026 22:25:17 +0300 Subject: [PATCH 20/22] Update test to check toggle visibility on grid scroll Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../igniteui-angular-elements/src/app/custom-strategy.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 add696e8bc6..13ce4cde1d6 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -726,6 +726,7 @@ describe('Elements: ', () => { gridEl.remove(); await firstValueFrom(timer(10 /* DESTROY_DELAY */ * 2)); + testContainer.appendChild(toggle); gridEl.dispatchEvent(new CustomEvent('gridScroll')); expect(toggle.hide).not.toHaveBeenCalled(); From 39d3c90d95befd1a100b4ba7466dfb3ad70535b7 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Wed, 21 Oct 2026 11:11:36 +0300 Subject: [PATCH 21/22] test(elements): fixing the unit test by isolating in config --- angular.json | 1 + .../src/app/custom-strategy-deferred.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index e53bfb73fa3..d00812f770b 100644 --- a/angular.json +++ b/angular.json @@ -455,6 +455,7 @@ "buildTarget": "igniteui-angular-elements:build:development,test", "tsConfig": "projects/igniteui-angular-elements/tsconfig.spec.json", "browsers": ["chromium"], + "isolate": true, "coverageInclude": [ "projects/igniteui-angular-elements/src/**/*.ts", "projects/igniteui-angular-elements/src/**/*.html" diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts index a008cb8ff8a..ccf04ad8217 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy-deferred.spec.ts @@ -7,7 +7,8 @@ import { describe, it, expect } from 'vitest'; describe('Elements: deferred parent definition', () => { it('should wait for the parent element to be defined before attaching to it', async () => { - // Relies on the per-file isolation of the test runner for a clean custom elements registry + // Relies on per-file isolation (`isolate: true` in angular.json) for a clean custom elements registry, + // otherwise other spec files sharing the same browser page may have already defined `igc-tree-grid` expect(customElements.get('igc-tree-grid')).toBeUndefined(); defineComponents(IgcColumnComponent); From 49c2b39e314ca68e277d6b914482da8ac76436a5 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Thu, 22 Oct 2026 14:28:48 +0300 Subject: [PATCH 22/22] chore(*): addressing copilot review comments --- .../src/app/custom-strategy.spec.ts | 69 +++++++++++++++++++ .../src/app/custom-strategy.ts | 13 ++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 2289c07877f..397bcd185ec 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -812,6 +812,75 @@ describe('Elements: ', () => { expect(outerGroup.selected).toBe(true); }); + 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 }; diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index 8defcc80010..0fb0dfa705c 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -34,8 +34,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { /** 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 that had no effect, - * because they depend on content children (e.g. column group `selected`). Retried once content queries resolve. + * 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 */ @@ -274,7 +274,9 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { 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)) { @@ -315,8 +317,9 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { 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 && componentRef.instance[property] !== value) { - // depends on content children that aren't resolved yet, retry once they are: + 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();