diff --git a/documentation/ag-grid-docs/src/utils/htaccess/cspRules.ts b/documentation/ag-grid-docs/src/utils/htaccess/cspRules.ts index 883918a0b63..476d7e0eaa9 100644 --- a/documentation/ag-grid-docs/src/utils/htaccess/cspRules.ts +++ b/documentation/ag-grid-docs/src/utils/htaccess/cspRules.ts @@ -52,6 +52,16 @@ const SALESFORCE_FORM_ORIGIN: Record = { production: 'https://webto.salesforce.com', }; +// The ecommerce checkout renders the Realex/Global Payments Hosted Payment Page +// (rxp-hpp.js) in an iframe and POSTs the payment form to it — sandbox host in +// non-prod, live host in production (see globalPaymentsServiceUrl in the +// ag-grid-ecommerce frontend environments). Governs frame-src and form-action. +const REALEX_HPP_ORIGIN: Record = { + dev: 'https://pay.sandbox.realexpayments.com', + staging: 'https://pay.sandbox.realexpayments.com', + production: 'https://pay.realexpayments.com', +}; + // Dev-server-only extras (HMR + cross-port preview). Never emitted for staging // or production. const DEV_SCRIPT_SRC = ['https://localhost:4610', 'https://localhost:4611']; @@ -61,6 +71,7 @@ export function getCspDirectives(options: CspOptions): CspDirectives { const { env } = options; const trialFormOrigin = options.trialFormOrigin ?? TRIAL_FORM_ORIGIN[env]; const salesforceFormOrigin = SALESFORCE_FORM_ORIGIN[env]; + const realexHppOrigin = REALEX_HPP_ORIGIN[env]; const directives: CspDirectives = { 'default-src': [SELF], @@ -69,6 +80,7 @@ export function getCspDirectives(options: CspOptions): CspDirectives { AG_GRID_HOSTS, 'https://plausible.io', 'https://www.googletagmanager.com', + 'https://www.google-analytics.com', // Universal Analytics analytics.js (GTM-injected after cookie consent) 'https://cdn.jsdelivr.net', 'https://cdnjs.cloudflare.com', 'https://js.zi-scripts.com', // ZoomInfo tag (injected via GTM) @@ -76,6 +88,8 @@ export function getCspDirectives(options: CspOptions): CspDirectives { 'https://www.google.com', // reCAPTCHA 'https://www.gstatic.com', // reCAPTCHA 'https://www.youtube.com', // YouTube iframe JS API (loads into the page) + 'https://cdn.cookielaw.org', // OneTrust cookie-consent SDK (GTM-injected, prod-only) + 'blob:', // ZoomInfo zi-tag.js bootstraps a blob: URL script UNSAFE_INLINE, UNSAFE_EVAL, ], @@ -105,7 +119,7 @@ export function getCspDirectives(options: CspOptions): CspDirectives { 'https://plausible.io', 'https://*.algolia.net', 'https://*.algolianet.com', - 'https://www.google-analytics.com', + 'https://*.google-analytics.com', // GA4 incl. regional collect endpoints (region1/2.google-analytics.com) 'https://*.analytics.google.com', 'https://stats.g.doubleclick.net', 'https://flagcdn.com', @@ -115,6 +129,10 @@ export function getCspDirectives(options: CspOptions): CspDirectives { 'https://js.zi-scripts.com', // ZoomInfo 'https://*.zoominfo.com', // ZoomInfo 'https://www.google.com', // reCAPTCHA (api2/clr XHR) + 'https://cdn.cookielaw.org', // OneTrust config/JSON/asset XHR (GTM-injected, prod-only) + 'https://*.onetrust.com', // OneTrust geolocation + consent-receipt endpoints + 'https://www.googleapis.com', // Firebase Auth (ecommerce checkout): identitytoolkit REST + 'https://securetoken.googleapis.com', // Firebase Auth ID-token refresh trialFormOrigin, ], 'frame-src': [ @@ -122,6 +140,7 @@ export function getCspDirectives(options: CspOptions): CspDirectives { 'https://www.googletagmanager.com', 'https://www.youtube.com', 'https://www.google.com', // reCAPTCHA challenge iframe + realexHppOrigin, // ecommerce checkout: Realex Hosted Payment Page iframe ], 'media-src': [SELF, 'data:', 'blob:', 'https:'], 'worker-src': [SELF, 'blob:'], @@ -131,6 +150,7 @@ export function getCspDirectives(options: CspOptions): CspDirectives { SELF, trialFormOrigin, salesforceFormOrigin, + realexHppOrigin, // ecommerce checkout: payment form POST to Realex HPP 'https://codesandbox.io', // example-runner "Open in CodeSandbox" form POST 'https://plnkr.co', // example-runner "Open in Plunker" form POST ], diff --git a/packages/ag-grid-community/src/columns/buildColumnTree.ts b/packages/ag-grid-community/src/columns/buildColumnTree.ts index 2cd5b7dfc97..be6d4c7f623 100644 --- a/packages/ag-grid-community/src/columns/buildColumnTree.ts +++ b/packages/ag-grid-community/src/columns/buildColumnTree.ts @@ -28,6 +28,8 @@ export interface ColumnTreeBuild { /** Cols keyed by `colId` / `userProvidedColDef` ref / `field`; for O(1) reuse. */ colsByKey: Map; source: ColumnEventType; + /** True = user (re)set the definitions, so reused cols re-apply stateful attrs; see {@link AgColumn.reapplyColDef}. */ + newColDefs: boolean; buildToken: number; /** Padding-wrapper cache for the editable (hierarchy/calc) path; `null` for pivot result trees * (a one-shot build that never splices). */ @@ -48,6 +50,7 @@ export function _buildColumnTree( existingColsByKey: Map, existingColsById: { readonly [id: string]: AgColumn }, source: ColumnEventType, + newColDefs: boolean, buildToken: number, wrapperCache: ColWrapperCache | null ): ColumnTreeBuild { @@ -160,7 +163,7 @@ export function _buildColumnTree( if (isReusableUserCol(existing) && userDef && userDef.colId == null && userDef.field == null) { allocatedKeys.add(id); existing.buildToken = buildToken; - existing.reapplyColDef(def, source); + existing.reapplyColDef(def, source, newColDefs); return existing; } continue; @@ -177,7 +180,7 @@ export function _buildColumnTree( const keyed = existingColsByKey.get(base); if (keyed?.colId === base && isReusableUserCol(keyed)) { keyed.buildToken = buildToken; - keyed.reapplyColDef(def, source); + keyed.reapplyColDef(def, source, newColDefs); return keyed; } let count = 0; @@ -201,7 +204,7 @@ export function _buildColumnTree( } if (existing !== undefined) { existing.buildToken = buildToken; - existing.reapplyColDef(def, source); + existing.reapplyColDef(def, source, newColDefs); return existing; } return _createUserColumn(beans, def, id, primaryColumns, buildToken); @@ -230,7 +233,7 @@ export function _buildColumnTree( } if (column !== undefined) { column.buildToken = buildToken; - column.reapplyColDef(def, source); + column.reapplyColDef(def, source, newColDefs); } else { const field = def.field; column = colId == null && field == null ? buildAnonymousColumn(def) : buildKeyedColumn(def, colId, field); @@ -275,6 +278,7 @@ export function _buildColumnTree( groupsById: newGroupsById, colsByKey: newColsByKey, source, + newColDefs, buildToken, wrapperCache, }; @@ -489,6 +493,7 @@ export function _buildColumnTree( groupsById: newGroupsById, colsByKey: newColsByKey, source, + newColDefs, buildToken, wrapperCache, }; diff --git a/packages/ag-grid-community/src/columns/columnModel.ts b/packages/ag-grid-community/src/columns/columnModel.ts index 731378e19a7..d684473bcdd 100644 --- a/packages/ag-grid-community/src/columns/columnModel.ts +++ b/packages/ag-grid-community/src/columns/columnModel.ts @@ -185,14 +185,15 @@ export class ColumnModel extends BeanStub implements NamedBean { const builder = _buildColumnTree( beans, - colDefs, - true, - this.colDefGroupsById, - this.colDefColsByKey, - this.colsById, - source, - this.nextBuildToken(), - this.hierarchyWrapperCache + /* defs */ colDefs, + /* primaryColumns */ true, + /* existingGroupsById */ this.colDefGroupsById, + /* existingColsByKey */ this.colDefColsByKey, + /* existingColsById */ this.colsById, + /* source */ source, + /* newColDefs */ newColDefs, + /* buildToken */ this.nextBuildToken(), + /* wrapperCache */ this.hierarchyWrapperCache ); groupHierarchyColSvc?.contributeTo(builder); calculatedColsSvc?.contributeTo(builder); diff --git a/packages/ag-grid-community/src/entities/agColumn.ts b/packages/ag-grid-community/src/entities/agColumn.ts index fce464148ca..91d685ca295 100644 --- a/packages/ag-grid-community/src/entities/agColumn.ts +++ b/packages/ag-grid-community/src/entities/agColumn.ts @@ -220,24 +220,27 @@ export class AgColumn return true; } - /** Re-apply `def` to `column`, keeping its colDef and runtime state in sync. */ - public reapplyColDef(def: ColDef, source: ColumnEventType): void { + /** Re-apply `def` to a reused column. Stateful attrs are only (re)applied when the user authored the + * definitions (`newColDefs`); an internal rebuild (e.g. calc-col add) must leave live state intact. */ + public reapplyColDef(def: ColDef, source: ColumnEventType, newColDefs: boolean): void { const merged = _addColumnDefaultAndTypes(this.beans, def, this.colId); this.setColDef(merged, def, source); - updateSomeColumnState( - this.beans, - this, - merged.hide, - merged.sort, - merged.sortIndex, - merged.pinned, - merged.flex, - source - ); - // Width is owned by the flex layout while flexing, so only set it when not. - const colFlex = this.flex; - if (colFlex == null || colFlex <= 0) { - this.setActualWidth(merged.width ?? this.actualWidth, source); + if (newColDefs) { + updateSomeColumnState( + this.beans, + this, + merged.hide, + merged.sort, + merged.sortIndex, + merged.pinned, + merged.flex, + source + ); + // Read `flex` after the state update so a flex→fixed switch applies before width. + const colFlex = this.flex; + if (colFlex == null || colFlex <= 0) { + this.setActualWidth(merged.width ?? this.actualWidth, source); + } } } diff --git a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts index 4622019a295..a80f8966089 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts @@ -639,7 +639,10 @@ export class CellCtrl extends BeanStub { // non of {field, valueGetter, showRowGroup} is bad in the users application, however for this edge case, it's // best always refresh and take the performance hit rather than never refresh and users complaining in support // that cells are not updating. - const noValueProvided = field == null && valueGetter == null && showRowGroup == null; + // a calculated column has no field/valueGetter/showRowGroup but DOES have a value (its + // expression), so it must not count as value-less here — otherwise it force-refreshes every + // pass and flashes on changes to unrelated columns instead of only when its value changes. + const noValueProvided = field == null && valueGetter == null && showRowGroup == null && !column.isCalculatedCol; const newData = params?.newData ?? false; const forceRefresh = noValueProvided || (params && (params.force || newData)); diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts index 023f06ea35a..9ab1652a3c9 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts @@ -142,13 +142,18 @@ export class CalculatedColumnForm extends Component { private setupFormFields(): void { const translate = this.getLocaleTextFunc(); - this.eTitle.setLabel(translate('calculatedColumnTitle', 'Title')).setValue(this.draft.headerName, true); + this.eTitle + .setLabel(translate('calculatedColumnTitle', 'Title')) + .setLabelAlignment('top') + .setValue(this.draft.headerName, true); this.eType .setLabel(translate('calculatedColumnType', 'Type')) + .setLabelAlignment('top') .addOptions(this.dataTypeOptions) .setValue(this.draft.cellDataType, true); this.eExpression .setLabel(translate('calculatedColumnExpression', 'Expression')) + .setLabelAlignment('top') .setInputPlaceholder(translate('calculatedColumnExpressionPlaceholder', 'Type here')) .setRows(3) .setValue(this.draft.calculatedExpression, true); diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts index 016c2d32309..c51184884ce 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts @@ -330,8 +330,9 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa // missing/removed anchor (or none) falls back to a plain append. const source = build.source; const buildToken = build.buildToken; + const newColDefs = build.newColDefs; dynamicColumns.forEach((dc, colId) => { - const agCol = this.getOrCreateAgColumn(dc, colId, buildToken, source); + const agCol = this.getOrCreateAgColumn(dc, colId, buildToken, source, newColDefs); agCol.buildToken = buildToken; // So the post-build sweep keeps the col alive. const anchorId = dc.anchorColId; if (anchorId != null && anchorId !== colId && staticColOverrides.get(anchorId) !== null) { @@ -389,7 +390,8 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa dc: DynamicCalculatedColumn, colId: string, buildToken: number, - source: ColumnEventType + source: ColumnEventType, + newColDefs: boolean ): AgColumn { const beans = this.beans; const existing = dc.instance; @@ -397,7 +399,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa // Reuse the owned instance (always alive: contributed/stamped every refresh, nulled when // parked/removed). Restamp + refresh colDef in case expression/cellDataType changed. existing.buildToken = buildToken; - existing.reapplyColDef(dc.colDef, source); + existing.reapplyColDef(dc.colDef, source, newColDefs); return existing; } const agCol = _createUserColumn(beans, dc.colDef, colId, true, buildToken); @@ -527,9 +529,9 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa title: this.getLocaleTextFunc()('calculatedColumn', 'Calculated Column'), component: form, width: 300, - height: 320, + height: 345, minWidth: 300, - minHeight: 280, + minHeight: 345, centered: true, movable: true, resizable: true, diff --git a/packages/ag-grid-enterprise/src/formula/formulaService.ts b/packages/ag-grid-enterprise/src/formula/formulaService.ts index 6012133c85c..8dbf718e5db 100644 --- a/packages/ag-grid-enterprise/src/formula/formulaService.ts +++ b/packages/ag-grid-enterprise/src/formula/formulaService.ts @@ -210,12 +210,12 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe if (node.rowPinned != null && node.pinnedSibling) { return; } - // evicts this row's and its pinned sibling's own formulas so same-row calculated - // columns re-query their expression during the normal row refresh. + // Evict this row's (and its pinned sibling's) formulas so same-row calculated columns + // re-evaluate, then invalidate every cached value — an editable formula in another row + // may reference the changed cell. The refresh neither forces nor suppresses flash, so + // dependent cells flash on a genuine value change in step with the edited column. this.dropRow(node); - if (this.active) { - this.bumpValueCacheAndRefresh(); - } + this.bumpValueCacheAndRefresh(false); }; const onNewColumnsLoaded = () => { if (!this.isEvaluationActive()) { @@ -374,9 +374,9 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe * Bulk-invalidate every cached value via a version bump (ASTs preserved) and repaint. * O(1); entries become stale on next read. */ - private bumpValueCacheAndRefresh(): void { + private bumpValueCacheAndRefresh(forceRefresh: boolean = true): void { this.valueCacheVersion++; - this.beans.rowRenderer.refreshCells(REFRESH_CELLS_PARAMS); + this.beans.rowRenderer.refreshCells(forceRefresh ? REFRESH_CELLS_PARAMS : undefined); } /** diff --git a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts index eadd3b145e3..b2f0839bb61 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts @@ -185,14 +185,16 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo const buildToken = beans.colModel.nextBuildToken(); const balanced = _buildColumnTree( beans, - colDefs, - false, - this.pivotGroupsById, - this.pivotColsByKey, - beans.colModel.colsById, - source, - buildToken, - null + /* defs */ colDefs, + /* primaryColumns */ false, + /* existingGroupsById */ this.pivotGroupsById, + /* existingColsByKey */ this.pivotColsByKey, + /* existingColsById */ beans.colModel.colsById, + /* source */ source, + // Generated cols: re-apply their stateful attrs each pivot rebuild (pre-live-state-flag behaviour). + /* newColDefs */ true, + /* buildToken */ buildToken, + /* wrapperCache */ null ); // `previousTree` (not `currentPivotTree`) covers the clear/restore window where `currentPivotTree` is // null but saved bean refs survive. Skip the sweep when the tree is missing or unchanged. diff --git a/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts b/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts index 79d9620d2e3..06e8e6cdd61 100644 --- a/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts +++ b/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts @@ -19,6 +19,7 @@ describe('column-model rewrite edge cases', () => { afterEach(() => { pivotGridsManager.reset(); gridsManager.reset(); + vi.restoreAllMocks(); }); const defColIds = (defs: (ColDef | any)[] | undefined): string[] => (defs ?? []).map((d) => d.colId ?? d.field); @@ -54,6 +55,8 @@ describe('column-model rewrite edge cases', () => { // A pivot build must not reuse a user (primary) column whose colId collides with a generated pivot // colId — reuse is scoped to the same build kind (primary vs pivot result), not just colKind 'user'. test('pivot build does not reuse a user column that shares a generated pivot colId', async () => { + // The colliding colId is expected to raise warning #273 (colId suffixed) — capture and assert it. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const api = pivotGridsManager.createGrid('g', { columnDefs: [ { field: 'country', rowGroup: true }, @@ -76,6 +79,9 @@ describe('column-model rewrite edge cases', () => { const pivotResult = api.getPivotResultColumns() ?? []; expect(pivotResult).not.toContain(userCol); expect(pivotResult.some((c) => c.getColId() === 'pivot_b_x_total_1')).toBe(true); + + // Verify the suffixing warning was actually raised (not silently swallowed). + expect(warnSpy.mock.calls.some((args) => String(args[0]).includes('warning #273'))).toBe(true); }); // P2-2: colId is imperative for REUSE — a column with a colId is never reused by field. Changing a diff --git a/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts b/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts index 2d3e289c29a..2a11029a401 100644 --- a/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts +++ b/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts @@ -1979,4 +1979,62 @@ describe('Column Mutations', () => { expect(colB.isAlive()).toBe(false); }); }); + + describe('setColumnDefs: stateful colDef attrs still apply on update (BC)', () => { + test('a present pinned colDef re-applies, overwriting a runtime unpin', async () => { + const api = gridsManager.createGrid('myGrid', { + rowData: [{ a: 1, b: 2 }], + columnDefs: [{ colId: 'a', pinned: 'left' }, { colId: 'b' }], + }); + await new GridColumns(api, 'pinned via colDef').checkColumns(` + LEFT + └── a width:200 + CENTER + └── b width:200 + `); + + api.setColumnsPinned(['a'], null); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getPinned()).toBeNull(); + + api.setGridOption('columnDefs', [{ colId: 'a', pinned: 'left' }, { colId: 'b' }]); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getPinned()).toBe('left'); + await new GridColumns(api, 'pinned re-applied on update').checkColumns(` + LEFT + └── a width:200 + CENTER + └── b width:200 + `); + await new GridRows(api, 'pinned re-applied on update - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 + `); + }); + + test('an absent pinned colDef preserves a runtime pin', async () => { + const api = gridsManager.createGrid('myGrid', { + rowData: [{ a: 1, b: 2 }], + columnDefs: [{ colId: 'a' }, { colId: 'b' }], + }); + + api.setColumnsPinned(['a'], 'left'); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getPinned()).toBe('left'); + + api.setGridOption('columnDefs', [{ colId: 'a', headerName: 'Alpha' }, { colId: 'b' }]); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getPinned()).toBe('left'); + await new GridColumns(api, 'runtime pin preserved on absent-pinned update').checkColumns(` + LEFT + └── a "Alpha" width:200 + CENTER + └── b width:200 + `); + await new GridRows(api, 'runtime pin preserved on absent-pinned update - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 + `); + }); + }); }); diff --git a/testing/behavioural/src/columns/order/pivot.test.ts b/testing/behavioural/src/columns/order/pivot.test.ts index f9bf7418951..8c3d2e9b425 100644 --- a/testing/behavioural/src/columns/order/pivot.test.ts +++ b/testing/behavioural/src/columns/order/pivot.test.ts @@ -2,7 +2,7 @@ import type { ColDef, ColGroupDef } from 'ag-grid-community'; import { ClientSideRowModelModule, NumberFilterModule, TextFilterModule } from 'ag-grid-community'; import { PivotModule } from 'ag-grid-enterprise'; -import { GridColumns, GridRows, TestGridsManager, applyTransactionChecked } from '../../test-utils'; +import { GridColumns, GridRows, TestGridsManager, applyTransactionChecked, asyncSetTimeout } from '../../test-utils'; import { getAutoGroupColumnIds, getColumnOrder, getColumnOrderFromState } from '../column-test-utils'; describe('pivotMode=true', () => { @@ -1759,4 +1759,42 @@ describe('pivotMode=true', () => { expect(pivotOrder(api)).toEqual([...first].reverse()); }); + + describe('pivot toggle preserves primary column live state', () => { + test('runtime pinned + width on a primary column survive toggling pivotMode on then off', async () => { + const api = gridsManager.createGrid('myGrid', { + rowData: [{ country: 'US', sport: 'swim', val: 1 }], + columnDefs: [ + { field: 'country', pinned: 'left' }, + { field: 'sport', pivot: true }, + { field: 'val', aggFunc: 'sum' }, + ], + }); + await asyncSetTimeout(0); + + api.setColumnsPinned(['country'], null); + api.applyColumnState({ state: [{ colId: 'country', width: 333 }] }); + await asyncSetTimeout(0); + expect(api.getColumn('country')!.getPinned()).toBeNull(); + expect(api.getColumn('country')!.getActualWidth()).toBe(333); + + api.setGridOption('pivotMode', true); + await asyncSetTimeout(0); + api.setGridOption('pivotMode', false); + await asyncSetTimeout(0); + + expect(api.getColumn('country')!.getPinned()).toBeNull(); + expect(api.getColumn('country')!.getActualWidth()).toBe(333); + await new GridColumns(api, 'primary live state preserved after pivot toggle').checkColumns(` + CENTER + ├── country "Country" width:333 + ├── sport "Sport" width:200 pivot + └── val "Val" width:200 aggFunc:sum + `); + await new GridRows(api, 'primary live state preserved after pivot toggle - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 country:"US" sport:"swim" val:1 + `); + }); + }); }); diff --git a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts index 243b1568166..fe552ed6f93 100644 --- a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts @@ -388,6 +388,102 @@ describe('calculated columns - display ordering', () => { `); }); + test('dialog add preserves runtime unpinned state from an initially pinned colDef', async () => { + const api = createGrid('calculated-dialog-preserves-unpinned-state', { + rowData: [{ id: 'r1', athlete: 'Michael Phelps', age: 23 }], + columnDefs: [{ field: 'athlete', pinned: 'left' }, { field: 'age' }], + }); + + await new GridColumns(api, 'dialog add preserves unpinned state setup').checkColumns(` + LEFT + └── athlete "Athlete" width:200 + CENTER + └── age "Age" width:200 + `); + + api.setColumnsPinned(['athlete'], null); + await asyncSetTimeout(0); + + await new GridColumns(api, 'dialog add preserves unpinned state after unpin').checkColumns(` + CENTER + ├── athlete "Athlete" width:200 + └── age "Age" width:200 + `); + + api.showColumnMenu('age'); + await asyncSetTimeout(10); + await clickColumnMenuItem('Add Calculated Column'); + await asyncSetTimeout(1); + + setExpression('[Age] * 2'); + clickDialogButton('Apply'); + await asyncSetTimeout(1); + + await new GridColumns(api, 'dialog add preserves unpinned state after calculated column add').checkColumns(` + CENTER + ├── athlete "Athlete" width:200 + ├── age "Age" width:200 + └── calculated_1 "New title" width:200 + `); + await new GridRows(api, 'dialog add preserves unpinned state - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:r1 athlete:"Michael Phelps" age:23 calculated_1:46 + `); + }); + + test('dialog add preserves a runtime sort that diverged from the colDef sort', async () => { + const api = createGrid('calculated-dialog-preserves-sort', { + rowData: [{ id: 'r1', a: 1, b: 2 }], + columnDefs: [{ field: 'a', sort: 'asc' }, { field: 'b' }], + }); + + api.applyColumnState({ state: [{ colId: 'a', sort: 'desc' }] }); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getSort()).toBe('desc'); + + const calc = await addViaDialog(api, 'b', '[a] + [b]'); + + // Adding a calc col must not reset 'a' back to its colDef sort ('asc'). + expect(api.getColumn('a')!.getSort()).toBe('desc'); + expect(order(api)).toEqual(['a', 'b', calc]); + await new GridColumns(api, 'preserved runtime sort after calc add').checkColumns(` + CENTER + ├── a "A" width:200 sort:desc + ├── b "B" width:200 + └── calculated_1 "New title" width:200 + `); + await new GridRows(api, 'preserved runtime sort after calc add - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:r1 a:1 b:2 calculated_1:3 + `); + }); + + test('dialog add preserves a runtime column width that diverged from the colDef width', async () => { + const api = createGrid('calculated-dialog-preserves-width', { + rowData: [{ id: 'r1', a: 1, b: 2 }], + columnDefs: [{ field: 'a', width: 150 }, { field: 'b' }], + }); + + api.applyColumnState({ state: [{ colId: 'a', width: 250 }] }); + await asyncSetTimeout(0); + expect(api.getColumn('a')!.getActualWidth()).toBe(250); + + const calc = await addViaDialog(api, 'b', '[a] + [b]'); + + expect(api.getColumn('a')!.getActualWidth()).toBe(250); + expect(order(api)).toEqual(['a', 'b', calc]); + await new GridColumns(api, 'preserved runtime width after calc add').checkColumns(` + CENTER + ├── a "A" width:250 + ├── b "B" width:200 + └── calculated_1 "New title" width:200 + `); + await new GridRows(api, 'preserved runtime width after calc add - rows').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:r1 a:1 b:2 calculated_1:3 + `); + }); + test('two calc cols from the same anchor stack newest-first (tree + display agree)', async () => { const api = createGrid('dialog-same-anchor', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }],