diff --git a/projects/igniteui-angular/grids/core/src/state-base.directive.ts b/projects/igniteui-angular/grids/core/src/state-base.directive.ts index d1e2afecf97..e399f9926bf 100644 --- a/projects/igniteui-angular/grids/core/src/state-base.directive.ts +++ b/projects/igniteui-angular/grids/core/src/state-base.directive.ts @@ -139,10 +139,11 @@ export class IgxGridStateBaseDirective { private FEATURES = { sorting: { getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { - const sortingState = context.currGrid.sortingExpressions; - sortingState.forEach(s => { - delete s.strategy; - delete s.owner; + const sortingState = context.currGrid.sortingExpressions.map(s => { + const copy = { ...s }; + delete copy.strategy; + delete copy.owner; + return copy; }); return { sorting: sortingState }; }, @@ -154,10 +155,7 @@ export class IgxGridStateBaseDirective { getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { const filteringState = context.currGrid.filteringExpressionsTree; if (filteringState) { - delete filteringState.owner; - for (const item of filteringState.filteringOperands) { - delete (item as IFilteringExpressionsTree).owner; - } + return { filtering: context.cloneFilteringTree(filteringState) }; } return { filtering: filteringState }; }, @@ -169,16 +167,7 @@ export class IgxGridStateBaseDirective { advancedFiltering: { getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { const filteringState = context.currGrid.advancedFilteringExpressionsTree; - let advancedFiltering: any; - if (filteringState) { - delete filteringState.owner; - for (const item of filteringState.filteringOperands) { - delete (item as IFilteringExpressionsTree).owner; - } - advancedFiltering = filteringState; - } else { - advancedFiltering = {}; - } + const advancedFiltering: any = filteringState ? context.cloneFilteringTree(filteringState) : {}; return { advancedFiltering }; }, restoreFeatureState: (context: IgxGridStateBaseDirective, state: IFilteringExpressionsTree): void => { @@ -226,21 +215,21 @@ export class IgxGridStateBaseDirective { }, restoreFeatureState: (context: IgxGridStateBaseDirective, state: IColumnState[]): void => { const newColumns = []; - + // Helper to restore column state without auto-persisting widths const restoreColumnState = (column: IgxColumnComponent | IgxColumnGroupComponent, colState: IColumnState) => { // Extract width to handle it separately const width = colState.width; delete colState.width; - + Object.assign(column, colState); - + // Only restore width if it was explicitly set by the user (not undefined) if (width !== undefined) { column.width = width; } }; - + state.forEach((colState) => { const hasColumnGroup = colState.columnGroup; const hasColumnLayouts = colState.columnLayout; @@ -257,9 +246,9 @@ export class IgxGridStateBaseDirective { } else { ref1.children.reset([]); } - + restoreColumnState(ref1, colState); - + ref1.grid = context.currGrid; if (colState.parent || colState.parentKey) { const columnGroup: IgxColumnGroupComponent = newColumns.find(e => e.columnGroup && (e.key ? e.key === colState.parentKey : e.header === ref1.parent)); @@ -277,7 +266,7 @@ export class IgxGridStateBaseDirective { } restoreColumnState(ref, colState); - + ref.grid = context.currGrid; if (colState.parent || colState.parentKey) { const columnGroup: IgxColumnGroupComponent = newColumns.find(e => e.columnGroup && (e.key ? e.key === colState.parentKey : e.header === ref.parent)); @@ -299,9 +288,11 @@ export class IgxGridStateBaseDirective { groupBy: { getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { const grid = context.currGrid; - const groupingExpressions = grid.groupingExpressions; - groupingExpressions.forEach(expr => { - delete expr.strategy; + const groupingExpressions = grid.groupingExpressions.map(expr => { + const copy = { ...expr }; + delete copy.strategy; + delete copy.owner; + return copy; }); const expansionState = grid.groupingExpansionState; const groupsExpanded = grid.groupsExpanded; @@ -677,6 +668,22 @@ export class IgxGridStateBaseDirective { } } + /** + * Recursively clones a filtering expression tree, stripping the `owner` property + * from each cloned node so the live tree is never mutated. + */ + private cloneFilteringTree(tree: IFilteringExpressionsTree): IFilteringExpressionsTree { + const copy: IFilteringExpressionsTree = { ...tree }; + delete copy.owner; + copy.filteringOperands = tree.filteringOperands.map(item => { + if ('filteringOperands' in item) { + return this.cloneFilteringTree(item as IFilteringExpressionsTree); + } + return { ...item }; + }); + return copy; + } + /** * This method builds a rehydrated IExpressionTree from a provided object. */ diff --git a/projects/igniteui-angular/grids/core/src/state.directive.spec.ts b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts index 101728f385d..760201be1a6 100644 --- a/projects/igniteui-angular/grids/core/src/state.directive.spec.ts +++ b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts @@ -803,6 +803,116 @@ describe('IgxGridState - input properties #grid', () => { expect(prodIdColumn.colEnd).toBe(1); }); + it('getState should not mutate live sorting expressions (strategy/owner)', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const customStrategy = DefaultSortingStrategy.instance(); + const owner = {} as any; + grid.sortingExpressions = [ + { fieldName: 'ProductID', dir: SortingDirection.Asc, ignoreCase: false, strategy: customStrategy, owner } + ]; + fix.detectChanges(); + + expect(grid.sortingExpressions[0].strategy).toBe(customStrategy, 'strategy should be set before getState'); + expect(grid.sortingExpressions[0].owner).toBe(owner, 'owner should be set before getState'); + + state.getState(false, 'sorting'); + + expect(grid.sortingExpressions[0].strategy).toBe(customStrategy, 'strategy should not be removed from live expressions after getState'); + expect(grid.sortingExpressions[0].owner).toBe(owner, 'owner should not be removed from live expressions after getState'); + }); + + it('getState should not mutate live groupBy expressions (strategy/owner)', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const customStrategy = DefaultSortingStrategy.instance(); + const owner = {} as any; + grid.groupingExpressions = [ + { fieldName: 'ProductID', dir: SortingDirection.Asc, ignoreCase: false, strategy: customStrategy, owner } + ]; + fix.detectChanges(); + + expect(grid.groupingExpressions[0].strategy).toBe(customStrategy, 'strategy should be set before getState'); + expect(grid.groupingExpressions[0].owner).toBe(owner, 'owner should be set before getState'); + + const serializedState = state.getState(false, 'groupBy') as IGridState; + const serializedGroupBy = (serializedState.groupBy?.expressions ?? []) as Array; + + expect(serializedGroupBy.length).toBe(1, 'serialized groupBy state should contain the configured expression'); + expect(serializedGroupBy[0].strategy).toBeUndefined('strategy should be removed from serialized groupBy expressions'); + expect(serializedGroupBy[0].owner).toBeUndefined('owner should be removed from serialized groupBy expressions'); + expect(grid.groupingExpressions[0].strategy).toBe(customStrategy, 'strategy should not be removed from live groupBy expressions after getState'); + expect(grid.groupingExpressions[0].owner).toBe(owner, 'owner should not be removed from live groupBy expressions after getState'); + }); + + it('getState should not mutate live filtering expressions (owner)', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const filteringTree = new FilteringExpressionsTree(FilteringLogic.And); + const productFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + productFilteringTree.filteringOperands.push({ + condition: IgxBooleanFilteringOperand.instance().condition('true'), + conditionName: 'true', + fieldName: 'InStock', + ignoreCase: true + }); + (productFilteringTree as IFilteringExpressionsTree).owner = 'nestedOwner'; + filteringTree.filteringOperands.push(productFilteringTree); + (filteringTree as IFilteringExpressionsTree).owner = 'rootOwner'; + grid.filteringExpressionsTree = filteringTree; + fix.detectChanges(); + + expect(grid.filteringExpressionsTree.owner).toBe('rootOwner', 'root owner should be set before getState'); + expect((grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree).owner) + .toBe('nestedOwner', 'nested owner should be set before getState'); + + state.getState(false, 'filtering'); + + expect(grid.filteringExpressionsTree.owner).toBe('rootOwner', 'root owner should not be removed from live filtering tree after getState'); + expect((grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree).owner) + .toBe('nestedOwner', 'nested owner should not be removed from live filtering operand after getState'); + }); + + it('getState should not mutate live advancedFiltering expressions (owner)', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const filteringTree = new FilteringExpressionsTree(FilteringLogic.And); + const productFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + productFilteringTree.filteringOperands.push({ + condition: IgxBooleanFilteringOperand.instance().condition('true'), + conditionName: 'true', + fieldName: 'InStock', + ignoreCase: true + }); + (productFilteringTree as IFilteringExpressionsTree).owner = 'nestedOwner'; + filteringTree.filteringOperands.push(productFilteringTree); + (filteringTree as IFilteringExpressionsTree).owner = 'rootOwner'; + grid.advancedFilteringExpressionsTree = filteringTree; + fix.detectChanges(); + + expect(grid.advancedFilteringExpressionsTree.owner).toBe('rootOwner', 'root owner should be set before getState'); + expect((grid.advancedFilteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree).owner) + .toBe('nestedOwner', 'nested owner should be set before getState'); + + state.getState(false, 'advancedFiltering'); + + expect(grid.advancedFilteringExpressionsTree.owner).toBe('rootOwner', 'root owner should not be removed from live advanced filtering tree after getState'); + expect((grid.advancedFilteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree).owner) + .toBe('nestedOwner', 'nested owner should not be removed from live advanced filtering operand after getState'); + }); + it('should preserve column widths when restoring state with all columns hidden', () => { const fix = TestBed.createComponent(IgxGridStateComponent); fix.detectChanges();