From cc6d91aa3ef6813c9b623ca5bb666da8e078a3b0 Mon Sep 17 00:00:00 2001 From: "ag-jira-agent-ci[bot]" <286720198+ag-jira-agent-ci[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:20:32 +0100 Subject: [PATCH 01/11] AG-18208 Resolve the columns tool panel's display name on read (#14863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AG-18208 Resolve the columns tool panel's display name on read The Columns Tool Panel snapshotted each row's display name onto its ColumnModelItem when the tree was built, while every other consumer of that name resolved it live. Nothing owned or invalidated the snapshot, so any name change without a tree rebuild left it stale: rows recreated from the items — on a group expand/collapse, or on scroll recycling — rendered the pre-edit name again, and the panel's search box matched against it too. Drop the snapshot. ColumnModelItem.displayName resolves through ColumnNameService on read, and the two row components delegate to it instead of keeping their own copies, leaving one resolution path and nothing to invalidate. Co-Authored-By: Claude Opus 5 (1M context) * AG-18208 Tidy the tool-panel label test helper and name the search test for what it asserts The child-label helper is polled from waitFor, so its throwaway fallback row is now destroyed and detached after the label is read rather than accumulating duplicate labels in the grid DOM. The search test asserts that a rename made before the search text is set is matched, so its name says that. Co-Authored-By: Claude Opus 5 (1M context) * AG-18208 Cover tool-panel search over a virtualised, never-rendered column The search walks the whole tool-panel tree, not the rendered rows, so a column the virtual list never materialised is still matched — and matched on its live name. Co-Authored-By: Claude Opus 5 (1M context) * AG-18208 Memoise the tool-panel display name against its inputs Resolving on read kept a user headerValueGetter on the search path: every pass over the tree called it for every item, and each rendered row read the name several times. Cache it against the two things that can change it - colModel.colDefsVersion and the column/group header-name override - so it resolves once per change instead of once per read. * fix tests --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Stephen Cooper --- .../src/columnToolPanel/agPrimaryColsList.ts | 9 +- .../src/columnToolPanel/columnModelItem.ts | 31 ++- .../columnToolPanel/toolPanelColumnComp.ts | 12 +- .../toolPanelColumnGroupComp.ts | 13 +- .../editable-header-name.test.ts | 177 ++++++++++++++++++ .../column-display-name.test.ts | 169 +++++++++++++++++ 6 files changed, 389 insertions(+), 22 deletions(-) create mode 100644 testing/behavioural/src/columnToolPanel/column-display-name.test.ts diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts index e5d0349ada6..0695257f152 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts @@ -413,7 +413,7 @@ export class AgPrimaryColsList extends Component { const removeFunc = item.removeEventListener.bind(item, 'expandedChanged', columnExpandedListener); this.destroyColumnItemFuncs.push(removeFunc); }; - const colNames = this.beans.colNames; + const beans = this.beans; const recursivelyBuild = ( tree: (AgColumn | AgProvidedColumnGroup)[], @@ -445,9 +445,8 @@ export class AgPrimaryColsList extends Component { return; } - const displayName = colNames.getDisplayNameForProvidedColumnGroup(null, columnGroup, 'columnToolPanel'); const item: ColumnModelItem = new ColumnModelItem( - displayName, + beans, columnGroup, depth, true, @@ -467,9 +466,7 @@ export class AgPrimaryColsList extends Component { return; } - const displayName = colNames.getDisplayNameForColumn(column, 'columnToolPanel'); - - parentList.push(new ColumnModelItem(displayName, column, depth)); + parentList.push(new ColumnModelItem(beans, column, depth)); }; this.destroyColumnTree(); diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts b/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts index 674e9986ab2..f1960d4933b 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts @@ -1,6 +1,6 @@ import { LocalEventService } from 'ag-stack'; -import type { AgColumn, AgProvidedColumnGroup, IEventEmitter, IEventListener } from 'ag-grid-community'; +import type { AgColumn, AgProvidedColumnGroup, BeanCollection, IEventEmitter, IEventListener } from 'ag-grid-community'; type ColumnModelItemEvent = 'expandedChanged'; export class ColumnModelItem implements IEventEmitter { @@ -13,8 +13,13 @@ export class ColumnModelItem implements IEventEmitter { private _expanded: boolean | undefined; public passesFilter: boolean; + private cachedName: string | null = null; + /** -1 never matches a real `colDefsVersion`, so the first read always resolves. */ + private cachedNameVersion: number = -1; + private cachedNameOverride: string | null = null; + constructor( - public readonly displayName: string | null, + private readonly beans: BeanCollection, columnOrGroup: AgColumn | AgProvidedColumnGroup, public readonly depth: number, public readonly group = false, @@ -29,6 +34,28 @@ export class ColumnModelItem implements IEventEmitter { } } + // OPTIMIZATION: resolved on read so a name change is picked up without rebuilding the tree, but + // memoised against its only two inputs so a user `headerValueGetter` runs once per change rather + // than once per read - search filtering and row recycling both read this for every item. + public get displayName(): string | null { + const { beans, group, columnGroup, column } = this; + const { colNames, colModel } = beans; + const version = colModel.colDefsVersion; + const override = group + ? (colModel.groupHeaderNameOverrides.get(columnGroup.groupId) ?? null) + : column.headerNameOverride; + + if (version !== this.cachedNameVersion || override !== this.cachedNameOverride) { + this.cachedNameVersion = version; + this.cachedNameOverride = override; + this.cachedName = group + ? colNames.getDisplayNameForProvidedColumnGroup(null, columnGroup, 'columnToolPanel') + : colNames.getDisplayNameForColumn(column, 'columnToolPanel'); + } + + return this.cachedName; + } + public get expanded(): boolean { return !!this._expanded; } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts index e7dfb721322..6586212e3f1 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts @@ -48,7 +48,6 @@ export class ToolPanelColumnComp extends Component { public readonly column: AgColumn; public readonly columnDepth: number; private eDragHandle: Element; - private displayName: string | null; private processingColumnStateChange = false; private tooltipFeature?: TooltipFeature; private labelRendererFeature?: ColumnSelectionLabelRendererFeature; @@ -63,10 +62,13 @@ export class ToolPanelColumnComp extends Component { private readonly source: ColumnSelectionPanelSource ) { super(); - const { column, depth, displayName } = modelItem; + const { column, depth } = modelItem; this.column = column; this.columnDepth = depth; - this.displayName = displayName; + } + + private get displayName(): string | null { + return this.modelItem.displayName; } public postConstruct(): void { @@ -217,12 +219,10 @@ export class ToolPanelColumnComp extends Component { } private onColDefChanged(): void { - const displayName = this.beans.colNames.getDisplayNameForColumn(this.column, 'columnToolPanel'); - this.displayName = displayName; if (this.labelRendererFeature) { this.labelRendererFeature.refresh(); } else { - this.eLabel.textContent = displayName; + this.eLabel.textContent = this.displayName; } this.refreshAriaLabel(); } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts index ad292ace338..258ff986818 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts @@ -68,7 +68,6 @@ export class ToolPanelColumnGroupComp extends Component { public readonly columnGroup: AgProvidedColumnGroup; public readonly columnDepth: number; - private displayName: string | null; private processingColumnStateChange = false; private tooltipFeature?: TooltipFeature; private labelRendererFeature?: ColumnSelectionLabelRendererFeature; @@ -82,10 +81,13 @@ export class ToolPanelColumnGroupComp extends Component { private readonly source: ColumnSelectionPanelSource ) { super(); - const { columnGroup, depth, displayName } = modelItem; + const { columnGroup, depth } = modelItem; this.columnGroup = columnGroup; this.columnDepth = depth; - this.displayName = displayName; + } + + private get displayName(): string | null { + return this.modelItem.displayName; } public postConstruct(): void { @@ -359,11 +361,6 @@ export class ToolPanelColumnGroupComp extends Component { if (event.columnGroup && event.columnGroup.getGroupId() !== this.columnGroup.groupId) { return; } - this.displayName = this.beans.colNames.getDisplayNameForProvidedColumnGroup( - null, - this.columnGroup, - 'columnToolPanel' - ); if (this.labelRendererFeature) { this.labelRendererFeature.refresh(); } else { diff --git a/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts b/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts index 6cf9a88d8f1..2cf5653db42 100644 --- a/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts +++ b/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts @@ -94,6 +94,40 @@ describe('Editable header name', () => { ); } + /** + * The virtual list only materialises rows that are in view, and jsdom gives it no height, so a + * child row has to be brought into view (or built from its model item, as the list itself does) + * before its rendered label can be read. + */ + async function childColumnLabel(toolPanel: any, gridDiv: HTMLElement, colId: string): Promise { + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const items = (listPanel.getDisplayedColsList() as any[]) ?? []; + const rowIndex = items.findIndex((item) => !item.group && item.column?.getColId() === colId); + if (rowIndex < 0) { + throw new Error(`Tool-panel column entry not found for colId="${colId}"`); + } + + listPanel['virtualList'].ensureIndexVisible(rowIndex); + await asyncSetTimeout(0); + + const rendered = listPanel['virtualList'].getComponentAt(rowIndex) as any; + if (rendered) { + return rendered.getGui().querySelector('.ag-column-select-column-label')?.textContent ?? null; + } + + const wrapper = document.createElement('div'); + wrapper.classList.add('ag-virtual-list-item'); + gridDiv.appendChild(wrapper); + const comp = listPanel['createComponentFromItem'](items[rowIndex], wrapper); + wrapper.appendChild(comp.getGui()); + const label = wrapper.querySelector('.ag-column-select-column-label')?.textContent ?? null; + // This helper is polled from `waitFor`, so the throwaway row must not outlive the read: a + // surviving copy would duplicate the labels that the other helpers select on. + listPanel.destroyBean(comp); + wrapper.remove(); + return label; + } + /** Open the "Edit Column Name" editor for a column via its tool-panel context menu. */ async function openEditor(toolPanel: any, gridDiv: HTMLElement, label: string): Promise { await openContextMenu(toolPanel, gridDiv, label); @@ -397,6 +431,149 @@ describe('Editable header name', () => { expect(api.getState().columnGroup?.headerNames).toEqual([{ groupId: 'athleteGroup', headerName: 'Renamed' }]); }); + test('an edited group label survives collapsing and expanding the group in the columns tool panel', async () => { + const { gridDiv, toolPanel } = await createGrid([ + { + groupId: 'athleteGroup', + headerName: 'Group', + headerNameEditable: true, + children: [{ field: 'athlete' }, { field: 'age' }], + } as any, + ]); + + const labels = () => + Array.from(gridDiv.querySelectorAll('.ag-column-select-column-label')).map((el) => el.textContent); + await waitFor(() => expect(labels()).toContain('Group')); + + const input = await openEditor(toolPanel, gridDiv, 'Group'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitFor(() => expect(labels()).toContain('Renamed')); + + // Toggling destroys and recreates the row components from their tool-panel model items. + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + expect(labels()).toContain('Renamed'); + + toolPanel.expandColumnGroups(); + await asyncSetTimeout(0); + expect(labels()).toContain('Renamed'); + }); + + test('an edited child column label survives collapsing and expanding its group in the columns tool panel', async () => { + const { gridDiv, toolPanel } = await createGrid([ + { + groupId: 'athleteGroup', + headerName: 'Group', + children: [{ field: 'athlete', headerNameEditable: true }, { field: 'age' }], + } as any, + ]); + + await waitFor(async () => expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Athlete')); + + const input = await openEditor(toolPanel, gridDiv, 'Athlete'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitFor(async () => expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Renamed')); + + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + toolPanel.expandColumnGroups(); + await asyncSetTimeout(0); + + expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Renamed'); + }); + + test('the tool-panel search box matches a name edited before the search text is set', async () => { + const { api, gridDiv, toolPanel } = await createGrid([{ field: 'athlete' }, { field: 'age' }]); + + const labels = () => + Array.from(gridDiv.querySelectorAll('.ag-column-select-column-label')).map((el) => el.textContent); + await waitFor(() => expect(labels()).toContain('Athlete')); + + api.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Renamed' }] }); + + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + listPanel.setFilterText('Renamed'); + + await waitFor(() => expect(labels()).toEqual(['Renamed'])); + }); + + test('the tool-panel search box matches a virtualised column that has never been rendered', async () => { + const manyCols = Array.from({ length: 80 }, (_, i) => ({ field: `col${i}`, headerName: `Col ${i}` })); + const { api, toolPanel } = await createGrid(manyCols); + + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const targetIndex = await waitFor(() => { + const index = (listPanel.getDisplayedColsList() as any[]).findIndex( + (item) => item.column?.getColId() === 'col79' + ); + expect(index).toBeGreaterThan(-1); + return index; + }); + + // Non-vacuous: the search below has to reach an item the virtual list never materialised. + expect(listPanel['virtualList'].getComponentAt(targetIndex)).toBeFalsy(); + + api.applyColumnState({ state: [{ colId: 'col79', headerName: 'Renamed' }] }); + listPanel.setFilterText('Renamed'); + + await waitFor(() => + expect((listPanel.getDisplayedColsList() as any[]).map((item) => item.displayName)).toEqual(['Renamed']) + ); + }); + + test('the tool panel resolves an unrendered group name only when something reads it', async () => { + let innerGroupToolPanelCalls = 0; + const { gridDiv, toolPanel } = await createGrid([ + { field: 'athlete', headerNameEditable: true }, + { + groupId: 'outerGroup', + headerName: 'Outer', + children: [ + { + groupId: 'innerGroup', + headerValueGetter: (params: any) => { + // The grid header and rendered rows re-resolve names on any rename, so the + // group is collapsed out of view below and only the tool panel's own + // resolutions are counted. + if (params.location === 'columnToolPanel') { + innerGroupToolPanelCalls++; + } + return 'Inner'; + }, + children: [{ field: 'age' }], + }, + ], + } as any, + ]); + + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + + // Baseline before typing: `columnHeaderEdit.applyMode` defaults to `live`, so the rename + // event fires per keystroke and a baseline taken after typing would miss the extra work. + const callsBeforeEdit = innerGroupToolPanelCalls; + const input = await openEditor(toolPanel, gridDiv, 'Athlete'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitForEditorClosed(); + + expect(innerGroupToolPanelCalls).toBe(callsBeforeEdit); + + // Non-vacuous: the counter does fire through the panel's own model item, so the assertion + // above is about the rename doing no work, not about the getter being unreachable. + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const innerItem = listPanel['allColsTree'][1].children.find( + (item: any) => item.columnGroup?.groupId === 'innerGroup' + ); + expect(innerItem.displayName).toBe('Inner'); + expect(innerGroupToolPanelCalls).toBe(callsBeforeEdit + 1); + }); + test('renaming one group does not recompute the header name of another group', async () => { let ageGroupGetterCalls = 0; const { api, gridDiv, toolPanel } = await createGrid([ diff --git a/testing/behavioural/src/columnToolPanel/column-display-name.test.ts b/testing/behavioural/src/columnToolPanel/column-display-name.test.ts new file mode 100644 index 00000000000..eb8b4c2e458 --- /dev/null +++ b/testing/behavioural/src/columnToolPanel/column-display-name.test.ts @@ -0,0 +1,169 @@ +import { waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { TestGridsManager } from 'ag-test-utils'; + +import type { ColDef, GridApi, HeaderValueGetterParams } from 'ag-grid-community'; +import { getGridElement } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; + +/** + * The columns tool panel resolves each entry's display name through `headerValueGetter`, so the name + * has to track colDef changes that reach a live column, while a user getter must not be re-run for + * reads that cannot have changed its output (searching, and re-rendering recycled rows). + */ +describe('columns tool panel display name', () => { + const gridMgr = new TestGridsManager({ modules: [AllEnterpriseModule] }); + + afterEach(() => { + gridMgr.reset(); + }); + + const sideBar = { + toolPanels: [ + { + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel', + }, + ], + defaultToolPanel: 'columns', + }; + + /** jsdom gives the virtual list no height, so the viewport needs one before it renders any row. */ + function renderVirtualList(root: ParentNode): void { + const viewport = root.querySelector('.ag-column-select-virtual-list-viewport') as HTMLElement; + Object.defineProperty(viewport, 'offsetHeight', { value: 300, configurable: true }); + viewport.dispatchEvent(new Event('scroll')); + } + + function labels(root: ParentNode): (string | null)[] { + return Array.from(root.querySelectorAll('.ag-column-select-column-label'), (el) => el.textContent); + } + + async function search(root: ParentNode, text: string): Promise { + const input = root.querySelector('.ag-column-select-header-filter-wrapper input') as HTMLInputElement; + await userEvent.clear(input); + if (text) { + await userEvent.type(input, text); + } + } + + test('an entry re-resolves its name when the colDef changes underneath it', async () => { + // Cell data type inference is the case that matters: it re-applies the colDef to the live + // column once row data arrives, without reloading columns, so the panel keeps its entries. + const api: GridApi = gridMgr.createGrid('columns-tool-panel-display-name', { + columnDefs: [ + { + field: 'athlete', + headerValueGetter: (params) => `Athlete (${params.column?.getColDef().cellDataType})`, + }, + { field: 'age' }, + ] satisfies ColDef[], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement)).toEqual(['Athlete (false)', 'Age']); + }); + // Search first, so the pre-inference name is resolved for every entry before it changes. + await search(gridElement, 'athlete'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (false)'])); + + api.setGridOption('rowData', [{ athlete: 'Michael Phelps', age: 23 }]); + + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (text)'])); + + await search(gridElement, 'athlete (text)'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (text)'])); + + await search(gridElement, 'athlete (false)'); + await waitFor(() => expect(labels(gridElement)).toEqual([])); + }); + + test('searching does not re-run a header value getter whose output cannot have changed', async () => { + let toolPanelCalls = 0; + const countingHeaderValueGetter = (params: HeaderValueGetterParams) => { + if (params.location === 'columnToolPanel') { + toolPanelCalls++; + } + return `Col ${params.column?.getColId()}`; + }; + const api: GridApi = await gridMgr.createGridAndWait('columns-tool-panel-display-name', { + columnDefs: Array.from({ length: 40 }, (_, i) => ({ + field: `field${i}`, + headerValueGetter: countingHeaderValueGetter, + })) satisfies ColDef[], + rowData: [{ field0: 'a' }], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement).length).toBeGreaterThan(0); + }); + + // Every entry is resolved by this first search, which has to test all 40 names. + await search(gridElement, 'field39'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Col field39'])); + const callsAfterFirstSearch = toolPanelCalls; + expect(callsAfterFirstSearch).toBeGreaterThanOrEqual(40); + + // Further searches re-test all 40 names and re-render rows, but nothing that feeds a name + // has changed, so no entry may consult the getter again. + await search(gridElement, 'field38'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Col field38'])); + await search(gridElement, ''); + await waitFor(() => expect(labels(gridElement).length).toBeGreaterThan(1)); + + expect(toolPanelCalls).toBe(callsAfterFirstSearch); + }); + + test('reverting a renamed column consults its header value getter again', async () => { + let toolPanelCalls = 0; + const api: GridApi = await gridMgr.createGridAndWait('columns-tool-panel-display-name', { + columnDefs: [ + { + field: 'athlete', + headerValueGetter: (params: HeaderValueGetterParams) => { + if (params.location === 'columnToolPanel') { + toolPanelCalls++; + } + return 'From Getter'; + }, + }, + { field: 'age' }, + ] satisfies ColDef[], + rowData: [{ athlete: 'Michael Phelps', age: 23 }], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement)).toEqual(['From Getter', 'Age']); + }); + + // An override wins outright over the getter, so renaming must not consult it at all. + const callsBeforeRename = toolPanelCalls; + api.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Renamed' }] }); + await waitFor(() => expect(labels(gridElement)).toEqual(['Renamed', 'Age'])); + await search(gridElement, 'renamed'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Renamed'])); + expect(toolPanelCalls).toBe(callsBeforeRename); + + // Reverting drops the override, so the name has to come from the getter again. The visible + // rows refresh in place; which rows the active search admits is only recomputed on the next + // search, so the reverted entry is still listed here under the stale "renamed" text. + api.applyColumnState({ state: [{ colId: 'athlete', headerName: null }] }); + await waitFor(() => expect(labels(gridElement)).toEqual(['From Getter'])); + expect(toolPanelCalls).toBeGreaterThan(callsBeforeRename); + + await search(gridElement, 'from getter'); + await waitFor(() => expect(labels(gridElement)).toEqual(['From Getter'])); + }); +}); From fd82b9ee076e3f7f6a6ca7bb710bd46f94e20bbc Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Wed, 19 Aug 2026 10:30:43 +0100 Subject: [PATCH 02/11] AG-18233 + AG-14491 advanced filters fixes (#14890) * AG-18233 + AG-14491 * AG-18233 + AG-14491 --- .../content/docs/filter-advanced/index.mdoc | 1 + .../advancedFilterExpressionService.ts | 48 +++- .../colFilterExpressionParser.ts | 95 ++++++-- .../filterExpressionOperators.ts | 30 ++- .../filters/advancedFilterBuilderHarness.ts | 3 +- .../ag-test-utils/src/widgets/dropdowns.ts | 12 +- .../af-options.test.ts | 151 +++++++++--- .../af-parser.test.ts | 220 +++++++++++++++++- ...vanced-filter-column-filter-parity.test.ts | 50 +++- 9 files changed, 527 insertions(+), 83 deletions(-) diff --git a/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc b/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc index e04e74647f4..9bbae8430d5 100644 --- a/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc @@ -144,6 +144,7 @@ For `text` and `object` Cell Data Types, `caseSensitive = true` can be set to en For `number`, `date`, `dateString`, `dateTime` and `dateTimeString` Cell Data Types, the following properties can be set to include blank values for the relevant options: - `includeBlanksInEquals = true` +- `includeBlanksInNotEqual = true` - `includeBlanksInLessThan = true` - `includeBlanksInGreaterThan = true` diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts index 1667bce2cec..894e8b8432d 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts @@ -1,4 +1,11 @@ -import { _exists, _parseBigIntOrNull, _parseDateTimeFromString, _serialiseDate, _toStringOrNull } from 'ag-stack'; +import { + _exists, + _hasOwn, + _parseBigIntOrNull, + _parseDateTimeFromString, + _serialiseDate, + _toStringOrNull, +} from 'ag-stack'; import type { AgColumn, @@ -31,6 +38,15 @@ import { } from './filterExpressionOperators'; import { getBigIntParser } from './filterExpressionUtils'; +/** The `filterParams` an Advanced Filter evaluator honours; the rest are column-filter UI concerns. */ +const COPIED_FILTER_PARAMS: (keyof FilterExpressionEvaluatorParams)[] = [ + 'caseSensitive', + 'includeBlanksInEquals', + 'includeBlanksInNotEqual', + 'includeBlanksInLessThan', + 'includeBlanksInGreaterThan', +]; + export class AdvancedFilterExpressionService extends BeanStub implements NamedBean { beanName = 'advFilterExpSvc' as const; @@ -269,9 +285,16 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe return entries; } - public getOperatorAutocompleteEntries(column: AgColumn, baseCellDataType: BaseCellDataType): AutocompleteEntry[] { - const activeOperators = this.getActiveOperators(column); - return this.getDataTypeExpressionOperator(baseCellDataType)!.getEntries(activeOperators); + /** The options the column offers: those of the data type that `filterParams.filterOptions` names, or all. */ + public getOperatorAutocompleteEntries( + column: AgColumn | null | undefined, + baseCellDataType?: BaseCellDataType + ): AutocompleteEntry[] { + const operatorForType = this.getDataTypeExpressionOperator(baseCellDataType); + if (!operatorForType) { + return []; + } + return operatorForType.getEntries(column ? this.getActiveOperators(column) : undefined); } public getJoinOperatorAutocompleteEntries(): AutocompleteEntry[] { @@ -293,7 +316,9 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe baseCellDataType?: BaseCellDataType, operator?: string ): FilterExpressionOperator | undefined { - return this.getDataTypeExpressionOperator(baseCellDataType)?.operators?.[operator!]; + const operators = this.getDataTypeExpressionOperator(baseCellDataType)?.operators; + // A model `type` such as `toString` must not resolve to an inherited member. + return operators && _hasOwn(operators, operator!) ? operators[operator!] : undefined; } public getExpressionJoinOperators(): { AND: string; OR: string } { @@ -364,14 +389,13 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe } const { filterParams } = column.colDef; if (filterParams) { - ['caseSensitive', 'includeBlanksInEquals', 'includeBlanksInLessThan', 'includeBlanksInGreaterThan'].forEach( - (param: keyof FilterExpressionEvaluatorParams) => { - const paramValue = filterParams[param]; - if (paramValue) { - params[param] = paramValue; - } + for (let i = 0, len = COPIED_FILTER_PARAMS.length; i < len; ++i) { + const param = COPIED_FILTER_PARAMS[i]; + const paramValue = filterParams[param]; + if (paramValue) { + params[param] = paramValue; } - ); + } } this.expressionEvaluatorParams[colId] = params; diff --git a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts index cda76fa88dc..f3500f28aaa 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts @@ -116,29 +116,32 @@ class OperatorParser implements Parser { public expectedNumOperands: number = 0; private operator: string = ''; private parsedOperator: string; + /** Last character of the resolved name; set once the region is settled. */ + private matchEndPosition: number | undefined; constructor( private readonly params: FilterExpressionParserParams, public readonly startPosition: number, - private readonly baseCellDataType: BaseCellDataType + private readonly baseCellDataType: BaseCellDataType, + private readonly column: AgColumn | null | undefined ) {} public parse(char: string, position: number): boolean | undefined { - if (char === ' ' || char === ')') { - const isMatch = this.parseOperator(false, position - 1); - if (isMatch) { - return true; - } else { + if (this.matchEndPosition == null) { + const isTerminator = char === ' ' || char === ')'; + if (!isTerminator || !this.parseOperator(false, position - 1)) { this.operator += char; + return undefined; } - } else { - this.operator += char; } - return undefined; + // A resolved name may run past the terminator that settled it, so the rest of it is consumed as read. + return position <= this.matchEndPosition! ? undefined : true; } public complete(position: number): void { - this.parseOperator(true, position); + if (this.matchEndPosition == null) { + this.parseOperator(true, position); + } } public getValidationError(): FilterExpressionValidationError | null { @@ -159,20 +162,53 @@ class OperatorParser implements Parser { return this.parsedOperator; } + /** + * Greedy, over the options the column offers: the longest name spelled here wins, so one that another name + * starts with - or one containing a terminator - resolves. + */ private parseOperator(fromComplete: boolean, endPosition: number): boolean { - const operatorForType = this.params.advFilterExpSvc.getDataTypeExpressionOperator(this.baseCellDataType)!; - const parsedOperator = operatorForType.findOperator(this.operator); + const { params, startPosition } = this; + const expression = params.expression; + const advFilterExpSvc = params.advFilterExpSvc; + const entries = advFilterExpSvc.getOperatorAutocompleteEntries(this.column, this.baseCellDataType); this.endPosition = endPosition; - if (parsedOperator) { - this.parsedOperator = parsedOperator; - const operator = operatorForType.operators[parsedOperator]; + + const minLength = endPosition - startPosition + 1; + const partialSearchValue = expression.slice(startPosition, endPosition + 1).toLocaleLowerCase() + ' '; + let matchedOperator: string | undefined; + let matchedLength = 0; + let isPartialMatch = false; + for (let i = 0, len = entries.length; i < len; ++i) { + const entry = entries[i]; + const displayValue = entry.displayValue ?? ''; + // Lengths come from the name as written: lower-casing can change them, `İ` becoming two units. + const lowerCaseDisplayValue = displayValue.toLocaleLowerCase(); + if ( + displayValue.length > matchedLength && + displayValue.length >= minLength && + isNameAt(expression, startPosition, displayValue, lowerCaseDisplayValue) + ) { + matchedOperator = entry.key; + matchedLength = displayValue.length; + } + if (lowerCaseDisplayValue.startsWith(partialSearchValue)) { + isPartialMatch = true; + } + } + + if (matchedOperator) { + const matchEndPosition = startPosition + matchedLength - 1; + this.parsedOperator = matchedOperator; + this.endPosition = matchEndPosition; + this.matchEndPosition = matchEndPosition; + const operator = advFilterExpSvc.getExpressionOperator(this.baseCellDataType, matchedOperator)!; this.expectedNumOperands = operator.numOperands; const operatorDisplayValue = operator.displayValue; - checkAndUpdateExpression(this.params, this.operator, operatorDisplayValue, endPosition); + const userValue = expression.slice(startPosition, matchEndPosition + 1); + checkAndUpdateExpression(params, userValue, operatorDisplayValue, matchEndPosition); this.operator = operatorDisplayValue; return true; } - const isPartialMatch = parsedOperator === null; if (fromComplete || !isPartialMatch) { this.valid = false; } @@ -180,6 +216,24 @@ class OperatorParser implements Parser { } } +/** + * A name only resolves where a terminator or the expression end follows it: were it allowed to run into the + * next character, the caller would drop that character and the text would name an option it does not spell. + */ +function isNameAt( + expression: string, + startPosition: number, + displayValue: string, + lowerCaseDisplayValue: string +): boolean { + const endPosition = startPosition + displayValue.length; + const nextChar = expression[endPosition]; + return ( + (nextChar === undefined || nextChar === ' ' || nextChar === ')') && + expression.slice(startPosition, endPosition).toLocaleLowerCase() === lowerCaseDisplayValue + ); +} + class OperandParser implements Parser { public readonly type = 'operand'; @@ -360,7 +414,12 @@ export class ColFilterExpressionParser { this.columnParser = new ColumnParser(this.params, i); parser = this.columnParser; } else if (!this.operatorParser) { - this.operatorParser = new OperatorParser(this.params, i, this.columnParser.baseCellDataType); + this.operatorParser = new OperatorParser( + this.params, + i, + this.columnParser.baseCellDataType, + this.columnParser.column + ); parser = this.operatorParser; } else { this.operandParser = new OperandParser( diff --git a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts index cd761f78a90..fb062b5f16c 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts @@ -8,6 +8,7 @@ import type { AutocompleteEntry } from './autocomplete/autocompleteParams'; export interface FilterExpressionEvaluatorParams { caseSensitive?: boolean; includeBlanksInEquals?: boolean; + includeBlanksInNotEqual?: boolean; includeBlanksInLessThan?: boolean; includeBlanksInGreaterThan?: boolean; valueConverter: (value: TValue, node: IRowNode) => ConvertedTValue; @@ -32,7 +33,6 @@ export interface DataTypeFilterExpressionOperators; }; getEntries(activeOperators?: string[]): AutocompleteEntry[]; - findOperator(displayValue: string): string | null | undefined; } export abstract class FilterExpressionOperators implements Record< @@ -115,10 +115,6 @@ export class TextFilterExpressionOperators implements DataTypeF return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate } = this.params; this.operators = { @@ -206,10 +202,6 @@ export class ScalarFilterExpressionOperators< return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate, equals } = this.params; this.operators = { @@ -234,8 +226,9 @@ export class ScalarFilterExpressionOperators< node, params, operand1!, - !!params.includeBlanksInEquals, - (v, o) => !equals(v, o) + !!params.includeBlanksInNotEqual, + (v, o) => !equals(v, o), + true ), numOperands: 1, }, @@ -310,12 +303,19 @@ export class ScalarFilterExpressionOperators< params: FilterExpressionEvaluatorParams, operand: ConvertedTValue, nullsMatch: boolean, - expression: (value: ConvertedTValue, operand: ConvertedTValue) => boolean + expression: (value: ConvertedTValue, operand: ConvertedTValue) => boolean, + isNegated?: boolean ): boolean { if (value == null) { return nullsMatch; } - return expression(params.valueConverter(value, node), operand); + const convertedValue = params.valueConverter(value, node); + // A value the data type cannot read is nothing to compare against, as the column filter's own validity + // gate decides: it matches no comparison, and so matches every negation of one. + if (convertedValue == null) { + return !!isNegated; + } + return expression(convertedValue, operand); } } @@ -330,10 +330,6 @@ export class BooleanFilterExpressionOperators implements DataTypeFilterExpressio return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate } = this.params; this.operators = { diff --git a/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts index 9158021c3d3..bedf5006dbb 100644 --- a/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts +++ b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts @@ -303,9 +303,10 @@ export class AdvancedFilterBuilderHarness { this.api.onFilterChanged(); // Recreating the rows is the whole point of this helper, so poll until the rendered rows are // new element instances — the only signal that can distinguish a rebuild from the original render. - await waitFor(() => { + await waitFor(async () => { nudgeVirtualList('.ag-advanced-filter-builder-virtual-list-viewport'); nudgeVirtualList('.ag-rich-select-virtual-list-viewport'); + await asyncSetTimeout(0); const rowsNow = Array.from(document.querySelectorAll(ITEM_WRAPPER)); if (rowsNow.length === 0 || rowsNow.some((row) => rowsBefore.includes(row))) { throw new Error('builder item rows were not recreated'); diff --git a/testing/ag-test-utils/src/widgets/dropdowns.ts b/testing/ag-test-utils/src/widgets/dropdowns.ts index e03d33a234d..bdb346d3541 100644 --- a/testing/ag-test-utils/src/widgets/dropdowns.ts +++ b/testing/ag-test-utils/src/widgets/dropdowns.ts @@ -11,7 +11,11 @@ import { firePointerLikeClick } from '../test-utils-events'; const RICH_SELECT_VIEWPORT = '.ag-rich-select-virtual-list-viewport'; -/** Nudges a VirtualList viewport so the grid re-runs drawVirtualRows against its (mocked) height. */ +/** + * Nudges a VirtualList viewport so the grid re-runs drawVirtualRows against its (mocked) height. + * A `waitFor` callback around a nudge that rebuilds rows must `await asyncSetTimeout(0)`, or it re-polls on + * that mutation as a microtask and starves its own deadline timer; a stable list re-renders nothing. + */ export function nudgeVirtualList(selector: string, root: ParentNode = document): void { const el = root.querySelector(selector); if (el) { @@ -66,10 +70,10 @@ export async function selectRichSelectRow(label: string, root: ParentNode = docu let rows: HTMLElement[] = []; let index = -1; // Polled on a time budget, not a tick count: the list mounts a macrotask after the click and a fixed - // number of ticks flakes under load. Nudging inside the callback is safe - `waitFor` arms an independent - // `setTimeout` for its deadline, so the mutations it observes only add polls. - await waitFor(() => { + // number of ticks flakes under load. + await waitFor(async () => { nudgeVirtualList(RICH_SELECT_VIEWPORT, root); + await asyncSetTimeout(0); rows = Array.from(root.querySelectorAll('.ag-rich-select-row')); index = rows.findIndex((r) => r.textContent?.trim() === label); if (index < 0) { diff --git a/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts b/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts index d289820edef..7299918a015 100644 --- a/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts @@ -11,6 +11,7 @@ import type { GridApi, GridOptions, IFilterOptionDef } from 'ag-grid-community'; import { ClientSideRowModelModule, DateFilterModule, + LocaleModule, NumberFilterModule, TextFilterModule, enableDevValidations, @@ -306,6 +307,22 @@ describe('Advanced Filter — currently-unsupported operators (baseline)', () => └── LEAF id:1 date:"2024-06-15" `); }); + + // A preset key names no AF option, so it can make none available: the list leaves only `equals`. + test('an operator absent from the preset list is not offered', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', OPTS); + + await AdvancedFilterHarness.get(api).applyExpression('[Date] > "2024-03-01"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'preset column greaterThan not offered').checkFilterDom(` + ADVANCED FILTER + input: "[Date] > "2024-03-01"" + valid: false — Expression has an error. Option not found - > "2024-03-01". + buttons: Apply ⊘ | Builder + model: null + `); + }); }); describe('inRange is not offered today', () => { @@ -382,42 +399,122 @@ describe('Advanced Filter — currently-unsupported operators (baseline)', () => `); }); }); +}); + +/** The docs promise `filterOptions` sets the options available to the Advanced Filter, not only its autocomplete. */ +describe('Advanced Filter — string filterOptions restrict the options', () => { + const gridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + DateFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); - describe('string filterOptions restrict autocomplete suggestions only, not the parser', () => { - const OPTS: GridOptions = { + afterEach(() => gridsManager.reset()); + + function optionsWithNotContainsNamed(notContainsName?: string): GridOptions { + return { columnDefs: [{ field: 'name', filter: 'agTextColumnFilter', filterParams: { filterOptions: ['equals'] } }], rowData: [{ name: 'Bolt' }, { name: 'Ng' }], enableAdvancedFilter: true, + localeText: notContainsName ? { advancedFilterNotContains: notContainsName } : undefined, }; + } - test('an operator outside the restricted list is still accepted by the parser', async () => { - const api: GridApi = await gridsManager.createGridAndWait('grid1', OPTS); + test('an option the list names parses', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); - // `contains` is a valid text operator but NOT in the column's restricted `filterOptions`. - // The restriction only trims the autocomplete suggestions — the parser still accepts it, - // so the expression applies. (Baseline for AG-10819: the operator set is not enforced.) - await AdvancedFilterHarness.get(api).applyExpression('[Name] contains "o"'); - await asyncSetTimeout(0); - expect(api.getAdvancedFilterModel()).toEqual({ - filterType: 'text', - colId: 'name', - type: 'contains', - filter: 'o', - }); - await new GridRows(api, 'restricted-but-parsed operator applied').check(` - ROOT id:ROOT_NODE_ID - └── LEAF id:0 name:"Bolt" - `); + await AdvancedFilterHarness.get(api).applyExpression('[Name] equals "Bolt"'); + await asyncSetTimeout(0); - await AdvancedFilterHarness.get(api).applyExpression('[Name] equals "Bolt"'); - await asyncSetTimeout(0); - expect(api.getAdvancedFilterModel()).toEqual({ - filterType: 'text', - colId: 'name', - type: 'equals', - filter: 'Bolt', - }); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'name', + type: 'equals', + filter: 'Bolt', + }); + await new GridRows(api, 'a named option').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bolt" + `); + }); + + test('an option the list omits is not a known option', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); + + await AdvancedFilterHarness.get(api).applyExpression('[Name] contains "o"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an omitted option').checkFilterDom(` + ADVANCED FILTER + input: "[Name] contains "o"" + valid: false — Expression has an error. Option not found - contains "o". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('a model naming an option the list omits does not apply', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); + + api.setAdvancedFilterModel({ filterType: 'text', colId: 'name', type: 'contains', filter: 'o' }); + await asyncSetTimeout(0); + + await new FilterDom(api, 'a model naming an omitted option').checkFilterDom(` + ADVANCED FILTER + input: "[Name] contains "o"" + valid: false — Expression has an error. Option not found - contains "o". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('an omitted longer name does not beat the shorter one it starts with', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed('equals not')); + + // `equals not` names the omitted `notContains`, so only `equals` is in the running: `not` becomes its + // operand and `"Bolt"` is left where a join operator belongs. + await AdvancedFilterHarness.get(api).applyExpression('[Name] equals not "Bolt"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an omitted longer name').checkFilterDom(` + ADVANCED FILTER + input: "[Name] equals not "Bolt"" + valid: false — Expression has an error. Join operator not found - "Bolt". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + // Presets have no expression form yet, so a list of only presets names nothing the parser can offer. + test('a list naming no Advanced Filter option leaves the column unfilterable', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + cellDataType: 'dateString', + filter: 'agDateColumnFilter', + filterParams: { filterOptions: ['today', 'yesterday'] }, + }, + ], + rowData: [{ date: '2024-01-01' }], + enableAdvancedFilter: true, }); + + await AdvancedFilterHarness.get(api).applyExpression('[Date] = "2024-01-01"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'a list naming no AF option').checkFilterDom(` + ADVANCED FILTER + input: "[Date] = "2024-01-01"" + valid: false — Expression has an error. Option not found - = "2024-01-01". + buttons: Apply ⊘ | Builder + model: null + `); }); }); diff --git a/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts b/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts index e529971631b..70df0197264 100644 --- a/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts @@ -1,7 +1,13 @@ import { AdvancedFilterHarness, FilterDom, GridRows, TestGridsManager, asyncSetTimeout } from 'ag-test-utils'; import type { GridApi, GridOptions } from 'ag-grid-community'; -import { ClientSideRowModelModule, DateFilterModule, NumberFilterModule, TextFilterModule } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + DateFilterModule, + LocaleModule, + NumberFilterModule, + TextFilterModule, +} from 'ag-grid-community'; import { AdvancedFilterModule } from 'ag-grid-enterprise'; /** @@ -409,6 +415,218 @@ describe('Advanced Filter — parser edge cases', () => { }); }); + describe('an operator name another one starts with', () => { + // Its own manager: `localeText` needs LocaleModule, and the names under test only exist through it. + const localeGridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); + + afterEach(() => localeGridsManager.reset()); + + function optionsWithNotContainsNamed(name: string): GridOptions { + return { ...OPTS, localeText: { advancedFilterNotContains: name } }; + } + + test('the longer name parses rather than the shorter one it starts with', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] contains not "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] contains not "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'notContains', + filter: 'e', + }); + await new GridRows(api, 'the longer operator name').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('the shorter name still parses on its own', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] contains "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] contains "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'e', + }); + await new GridRows(api, 'the shorter operator name').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 athlete:"Wei" age:28 big:"10000000000000000001n" + `); + }); + + test('a model naming the longer option round-trips through the expression', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + + api.setAdvancedFilterModel({ filterType: 'text', colId: 'athlete', type: 'notContains', filter: 'e' }); + await asyncSetTimeout(0); + + expect(AdvancedFilterHarness.get(api).value).toBe('[Athlete] contains not "e"'); + await new GridRows(api, 'a model naming the longer option').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('a longer name left incomplete falls back to the shorter match, and the rest is rejected', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not really') + ); + + // Greedy but not destructive: only `contains` resolves, so `not` is its operand and `"e"` is left + // where a join operator belongs - the text is reported rather than silently swallowed by the scan. + await AdvancedFilterHarness.get(api).applyExpression('[Athlete] contains not "e"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an incomplete longer name').checkFilterDom(` + ADVANCED FILTER + input: "[Athlete] contains not "e"" + valid: false — Expression has an error. Join operator not found - "e". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('an operand continuing the longer name falls back to the shorter one rather than failing', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + // `notes` spells `not` and then keeps going, so the longer name is not what the text says. + await af.applyExpression('[Athlete] contains notes'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'notes', + }); + await new GridRows(api, 'an operand continuing the longer name').check(` + ROOT id:ROOT_NODE_ID + `); + }); + + test('a name containing a closing bracket parses', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('not (contains)') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] not (contains) "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] not (contains) "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'notContains', + filter: 'e', + }); + await new GridRows(api, 'a bracketed operator name').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('a bracket closing the enclosing group is not taken as part of the name', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('not (contains)') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('([Athlete] not (contains) "e") OR [Age] = 40'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'join', + type: 'OR', + conditions: [ + { filterType: 'text', colId: 'athlete', type: 'notContains', filter: 'e' }, + { filterType: 'number', colId: 'age', type: 'equals', filter: 40 }, + ], + }); + await new GridRows(api, 'a bracketed name inside a group').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + }); + + describe('an operator name whose lowercase is longer than the name', () => { + const localeGridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); + + afterEach(() => localeGridsManager.reset()); + + // `İ` lowercases to two UTF-16 units, so the name is longer lowercased than as typed: a boundary + // taken from the lowercased form overruns the name and never matches what the text spells. + test('a name containing a dotted capital I parses', async () => { + const api = await localeGridsManager.createGridAndWait('grid1', { + ...OPTS, + localeText: { advancedFilterContains: 'İçerir' }, + }); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] İçerir "e"'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'e', + }); + await new GridRows(api, 'a dotted capital I in the name').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 athlete:"Wei" age:28 big:"10000000000000000001n" + `); + }); + }); + describe('bigint operand', () => { test('a bigint literal beyond Number.MAX_SAFE_INTEGER filters exactly', async () => { const api = await gridsManager.createGridAndWait('grid1', OPTS); diff --git a/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts b/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts index 1de55173f59..5e007f1537b 100644 --- a/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts +++ b/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts @@ -89,12 +89,10 @@ describe('Advanced Filter matches the column filter', () => { { colId: 'athlete', filterType: 'text' as const, filter: 'alpha' }, { colId: 'age', filterType: 'number' as const, filter: 2 }, ])('$colId', ({ colId, filterType, filter }) => { - // A number column's `notEqual` is not at parity: the Advanced Filter admits blanks to it by - // `includeBlanksInEquals`, where the column filter reads `includeBlanksInNotEqual`. const valuedOptions = filterType === 'text' ? ['contains', 'notContains', 'equals', 'notEqual', 'startsWith', 'endsWith'] - : ['equals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; + : ['equals', 'notEqual', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; test.each(valuedOptions)('`%s` filters the same rows', async (type) => { const columnFilterIds = await withColumnFilter(colId, { filterType, type, filter }); @@ -122,6 +120,52 @@ describe('Advanced Filter matches the column filter', () => { }); }); + test('`includeBlanksInNotEqual` admits a blank to `notEqual` in both', async () => { + const columnFilterIds = await withColumnFilter('age', { filterType: 'number', type: 'notEqual', filter: 2 }); + + expect(columnFilterIds).toContain(3); // the row with a null age + expect(await withAdvancedFilter({ filterType: 'number', colId: 'age', type: 'notEqual', filter: 2 })).toEqual( + columnFilterIds + ); + }); + + // The column filter's `isValid(cellValue)` gate keeps an unreadable date out of the comparison; the + // Advanced Filter has no equivalent and converts before it compares. + test('an unreadable date compares rather than throwing, in both', async () => { + expect(await withColumnFilter('date', { filterType: 'date', type: 'equals', dateFrom: '2008-08-24' })).toEqual([ + 0, + ]); + expect( + await withAdvancedFilter({ + filterType: 'dateString', + colId: 'date', + type: 'equals', + filter: '2008-08-24', + }) + ).toEqual([0]); + }); + + // The negated half of the same gate, and the only case in which an unreadable value is a match. + test('an unreadable date is admitted to `notEqual`, in both', async () => { + const columnFilterIds = await withColumnFilter('date', { + filterType: 'date', + type: 'notEqual', + dateFrom: '2008-08-24', + }); + + expect(columnFilterIds).toContain(1); // whitespace + expect(columnFilterIds).toContain(4); // 'not a date' + expect(columnFilterIds).not.toContain(2); // null is blank, not unreadable + expect( + await withAdvancedFilter({ + filterType: 'dateString', + colId: 'date', + type: 'notEqual', + filter: '2008-08-24', + }) + ).toEqual(columnFilterIds); + }); + // A whitespace date is rejected as an invalid date before either filter asks whether it is blank, so // `isBlank`'s treatment of whitespace never reaches it. Pinned because it is the surprise. test('a whitespace `dateString` is not blank to either', async () => { From 4959f87a7a94dc3352f5b223db1c2f786d2cda90 Mon Sep 17 00:00:00 2001 From: Tak Tran Date: Wed, 19 Aug 2026 10:40:23 +0100 Subject: [PATCH 03/11] AG-3390 - Ignore e2e tests for now (#14899) --- .github/workflows/ci.yml | 53 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee83e05de66..2d7b98c9c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,31 +326,34 @@ jobs: # than a legitimately-unavailable environment. Local runs without Apache still skip. HTTPD_REQUIRED: 1 steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - with: - fetch-depth: 1 # shallow copy - - - name: Setup - id: setup - uses: ./.github/actions/setup-nx - with: - yarn_postinstall: no-install - cache_mode: ro - - - name: nx test:e2e - id: tests - run: yarn nx ${{ github.event.inputs.nx_command || 'affected' }} -t test:e2e -c - staging --exclude 'tag:module-size' --exclude all - - - name: Persist test results - if: always() && matrix.shard != 0 - uses: actions/upload-artifact@v4 - with: - name: test-results-e2e-shard-${{matrix.shard}} - path: | - reports/ + - name: Ignore e2e tests + run: echo "::warning e2e tests are currently disabled due to flakiness. Re-enable when fixed." + + # - name: Checkout + # id: checkout + # uses: actions/checkout@v4 + # with: + # fetch-depth: 1 # shallow copy + + # - name: Setup + # id: setup + # uses: ./.github/actions/setup-nx + # with: + # yarn_postinstall: no-install + # cache_mode: ro + + # - name: nx test:e2e + # id: tests + # run: yarn nx ${{ github.event.inputs.nx_command || 'affected' }} -t test:e2e -c + # staging --exclude 'tag:module-size' --exclude all + + # - name: Persist test results + # if: always() && matrix.shard != 0 + # uses: actions/upload-artifact@v4 + # with: + # name: test-results-e2e-shard-${{matrix.shard}} + # path: | + # reports/ lint: runs-on: ubuntu-latest From 58cdb0b26bc95bbae4c1d3bec412476493364d1a Mon Sep 17 00:00:00 2001 From: Tak Tran Date: Wed, 19 Aug 2026 10:45:48 +0100 Subject: [PATCH 04/11] AG-3390 Route history writes through a shared helper (#14892) Astro's ClientRouter keeps its history index and scroll offsets in `history.state`. Its popstate handler returns early when that state is null, so a bare `history.replaceState(null, '', url)` leaves back and forward moving the address bar without ever swapping the document. A partial state such as `{}` clears the null check but drops `index`, which makes every traversal read as "back" and turns the router's next push index into NaN. Copying a heading's link icon or changing the demo toolbar's dropdown both hit the `{}` case, corrupting history for the rest of the session. Add `replaceHistoryUrl()` to ag-website-shared and route every call site through it. It preserves the existing entry's state, and takes an optional patch for pages that need to store their own state alongside the router's bookkeeping. The file is identical to the copies in ag-charts and ag-studio so the next subrepo sync is a no-op. License-setup's search params switch from push to replace: the checkbox filters the page rather than navigates it, and no page-level popstate handler exists to service the entries a push creates. --- .../src/components/example-grid/Toolbar.tsx | 3 ++- .../license-setup/utils/updateSearchParams.ts | 6 +++++- .../src/components/changelog/Changelog.tsx | 3 ++- .../src/components/changelog/useSearchQuery.ts | 3 ++- .../src/components/link-icon/LinkIcon.tsx | 3 ++- .../components/general/GetTheme.tsx | 3 ++- .../ag-website-shared/src/utils/historyUrl.ts | 18 ++++++++++++++++++ 7 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 external/ag-website-shared/src/utils/historyUrl.ts diff --git a/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx b/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx index c0916113636..8be93cfc06d 100644 --- a/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx +++ b/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx @@ -1,4 +1,5 @@ import { Select } from '@ag-website-shared/components/select/Select'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { trackDemoToolbar } from '@utils/analytics'; import { useMemo } from 'react'; import type { RefObject } from 'react'; @@ -16,7 +17,7 @@ function updateUrlParam(key: string, value: string) { } const url = new URL(window.location.href); url.searchParams.set(key, value); - history.replaceState({}, '', url); + replaceHistoryUrl(url); } interface SelectOption { diff --git a/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts b/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts index c392a12c6d5..d469dc9d146 100644 --- a/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts +++ b/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts @@ -1,3 +1,5 @@ +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; + export const updateSearchParams = ({ integratedCharts }: { integratedCharts: boolean }) => { const url = new URL(window.location); const integratedChartsParamValue = url.searchParams.get('integratedCharts') === 'true'; @@ -10,5 +12,7 @@ export const updateSearchParams = ({ integratedCharts }: { integratedCharts: boo } } - history.pushState(null, '', url); + // A filter, not a navigation: no page-level popstate handler services these entries, so + // pushing one leaves back moving the URL with nothing reacting to it. + replaceHistoryUrl(url); }; diff --git a/external/ag-website-shared/src/components/changelog/Changelog.tsx b/external/ag-website-shared/src/components/changelog/Changelog.tsx index e5c27ac436a..920e0764b55 100644 --- a/external/ag-website-shared/src/components/changelog/Changelog.tsx +++ b/external/ag-website-shared/src/components/changelog/Changelog.tsx @@ -6,6 +6,7 @@ import { useSearchQuery } from '@ag-website-shared/components/changelog/useSearc import DetailCellRenderer from '@ag-website-shared/components/grid/DetailCellRendererComponent'; import { Grid } from '@ag-website-shared/components/grid/Grid'; import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { IssueColDef, IssueTypeColDef } from '@ag-website-shared/utils/issueColDefs'; import ReleaseVersionNotes from '@components/release-notes/ReleaseVersionNotes.jsx'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; @@ -188,7 +189,7 @@ export const Changelog: FunctionComponent = ({ library }) => { } url.searchParams.set('fixVersion', fixVersion); - window.history.replaceState({}, '', url); + replaceHistoryUrl(url); }, [setFixVersion] ); diff --git a/external/ag-website-shared/src/components/changelog/useSearchQuery.ts b/external/ag-website-shared/src/components/changelog/useSearchQuery.ts index 7d428f1185a..0339f5558db 100644 --- a/external/ag-website-shared/src/components/changelog/useSearchQuery.ts +++ b/external/ag-website-shared/src/components/changelog/useSearchQuery.ts @@ -1,3 +1,4 @@ +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; export function useSearchQuery() { @@ -29,7 +30,7 @@ export function useSearchQuery() { } else { url.searchParams.delete('searchQuery'); } - window.history.replaceState(null, '', url.toString()); + replaceHistoryUrl(url); }, 250); return () => clearTimeout(timeoutId); diff --git a/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx b/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx index 06100bae933..41b02db4f68 100644 --- a/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx +++ b/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx @@ -1,6 +1,7 @@ import { $copyFrameworkAgnosticLinks } from '@ag-website-shared/components/dev-tools/stores/devToolsStore'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { getFrameworkRedirectUrl } from '@ag-website-shared/utils/getFrameworkRedirectUrl'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { useStoreSsr } from '@utils/hooks/useStoreSsr'; import classnames from 'classnames'; import { type AllHTMLAttributes, useEffect, useRef, useState } from 'react'; @@ -27,7 +28,7 @@ export function LinkIcon({ navigator.clipboard.writeText(redirectUrl ?? href); - history.replaceState({}, '', hash); + replaceHistoryUrl(hash); setLinkCopied(true); setlinkActive(true); diff --git a/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx b/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx index b819e7269c9..00e3e744144 100644 --- a/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx +++ b/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx @@ -1,4 +1,5 @@ import { UIPopupButton } from '@ag-website-shared/components/theme-builder/UIPopupButton'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import styled from '@emotion/styled'; import { ThemeImportExportDialog } from './ThemeImportExportDialog'; @@ -16,7 +17,7 @@ export const GetThemeButton = () => ( initialOpen={hasImportHash()} onClose={() => { if (hasImportHash()) { - history.replaceState(null, '', window.location.pathname + window.location.search); + replaceHistoryUrl(window.location.pathname + window.location.search); } }} > diff --git a/external/ag-website-shared/src/utils/historyUrl.ts b/external/ag-website-shared/src/utils/historyUrl.ts new file mode 100644 index 00000000000..b00ff093a80 --- /dev/null +++ b/external/ag-website-shared/src/utils/historyUrl.ts @@ -0,0 +1,18 @@ +/** + * Rewrite the current URL, or attach page state to the current entry, without navigating. + * + * Astro's `` tracks its position in session history through `history.state`, + * and its popstate handler bails out when that state is missing. A direct + * `history.replaceState(null, '', url)` therefore disables back and forward for the whole + * page: the browser moves the URL, but the router never swaps the document. A partial state + * such as `{}` survives the null check but loses the index that distinguishes a back + * traversal from a forward one. + * + * Pass `statePatch` to store page state alongside the router's own bookkeeping - it spreads + * unknown keys through untouched, so both survive in the same entry. Omit `url` to leave the + * address bar alone and patch state only. + */ +export function replaceHistoryUrl(url?: string | URL, statePatch?: Record): void { + const state = statePatch ? { ...history.state, ...statePatch } : history.state; + history.replaceState(state, '', url); +} From 461ccb6c61b27c7b3748299dd7ffa3c33c85148b Mon Sep 17 00:00:00 2001 From: David Skewis Date: Wed, 19 Aug 2026 10:47:46 +0100 Subject: [PATCH 05/11] AG-14527 Smooth the homepage framework word animation (#14898) The outgoing word was unmounted instantly with no exit animation, and the incoming one slid in from above over 0.25s on a curve that overshot its resting position, where the container's `overflow: hidden` clipped it. Swaps are now a handover: the outgoing word accelerates away and fades, and the incoming word decelerates into place behind it. Both travel a fraction of a line, because the line box is barely taller than the glyphs and a full-line carousel would slice through them while still opaque. The word present on first paint no longer animates, so nothing moves in the heading as the page loads. --- .../FrameworkTextAnimation.module.scss | 41 ++++++++++++++++++- .../FrameworkTextAnimation.tsx | 29 ++++++++----- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss index 2ac67cfae65..bb78d55fc10 100644 --- a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss +++ b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss @@ -2,6 +2,14 @@ .animatedWordsOuter { --word-height: 1.2em; + --word-duration: 340ms; + + // The incoming word waits for the outgoing one to fade rather than crossing through it. + --word-handover-delay: 140ms; + + // Not a full line: the line box is barely taller than the glyphs, so a word travelling + // that far would be sliced by `overflow: hidden` while still opaque. + --word-travel: 40%; display: inline-grid; height: var(--word-height); @@ -26,16 +34,28 @@ .animatedWord { display: inline-block; - animation: framework-word-in 0.25s cubic-bezier(0.6, 0.3, 0.5, 1.4); +} + +.wordIn { + animation: framework-word-in var(--word-duration) cubic-bezier(0.16, 1, 0.3, 1) var(--word-handover-delay) both; @media (prefers-reduced-motion: reduce) { animation: none; } } +.wordOut { + animation: framework-word-out var(--word-duration) cubic-bezier(0.4, 0, 1, 1) both; + + // Without the animation to lift it away, it would sit on top of the current word. + @media (prefers-reduced-motion: reduce) { + display: none; + } +} + @keyframes framework-word-in { from { - transform: translateY(-100%); + transform: translateY(var(--word-travel)); opacity: 0; } @@ -45,6 +65,23 @@ } } +@keyframes framework-word-out { + from { + transform: translateY(0); + opacity: 1; + } + + // Gone before the incoming word becomes legible, though still drifting upwards. + 55% { + opacity: 0; + } + + to { + transform: translateY(calc(var(--word-travel) * -1)); + opacity: 0; + } +} + .javascript { color: #f7df1e; // Yellow } diff --git a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx index 3892e403425..f87b2053bee 100644 --- a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx +++ b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx @@ -27,32 +27,41 @@ const CYCLE_MS = 2500; const LONGEST_WORD = WORDS.reduce((longest, w) => (w.text.length > longest.length ? w.text : longest), ''); export const FrameworkTextAnimation: FunctionComponent = ({ prefix, suffix }) => { - const [wordIndex, setWordIndex] = useState(0); + // A monotonic count, not an index: the outgoing word stays derivable and `key` changes each swap. + const [cycle, setCycle] = useState(0); const prefixText = prefix ? `${prefix} ` : ''; const suffixText = suffix ? ` ${suffix}` : ''; useEffect(() => { const timeout = setTimeout(() => { - setWordIndex((index) => (index + 1) % WORDS.length); + setCycle((current) => current + 1); }, CYCLE_MS); return () => clearTimeout(timeout); - }, [wordIndex]); + }, [cycle]); - const word = WORDS[wordIndex]; + const word = WORDS[cycle % WORDS.length]; + // Nothing to animate away on first paint, so the server-rendered word settles without moving. + const outgoingWord = cycle > 0 ? WORDS[(cycle - 1) % WORDS.length] : undefined; - // One visible word at a time so the H1 reads as a single clean heading for crawlers - // and screen readers. The sizer is an aria-hidden copy of the widest word that stays - // in the DOM purely to reserve width — the two are stacked in the same grid cell, so - // the container width is fixed to the widest word and never shifts as words cycle. - // `key` retriggers the entry animation on swap. + // Only the current word is announced or server-rendered, so the H1 stays a single clean + // heading for crawlers and screen readers; the outgoing copy and the sizer are aria-hidden. return ( - + {outgoingWord && ( + + )} + 0 && styles.wordIn, word.className)}> {`${prefixText}${word.text}${suffixText}`} From dec8eb2d7023f37c9bee2cdbd14183466934553b Mon Sep 17 00:00:00 2001 From: "ag-jira-agent-ci[bot]" <286720198+ag-jira-agent-ci[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:43:01 +0100 Subject: [PATCH 06/11] test(set-filter): cover grid-level setFilterModel path for AG-17369 (#14889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AG-17369 regression tests drive the filter through column-level api.setColumnFilterModel, but the reporter and QA (plnkr 41sqYmiFjrSjJN3S) use grid-level api.setFilterModel — which queues the model and replays it against the still-empty client-side row model while cellDataType inference is pending. Add a test exercising that entry point, reusing one columnDefs object across a destroy/recreate reset, asserting both dropdown-open orderings converge on the applied ['one'] model (one row, only 'one' ticked). Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .../set-filter-empty-before-data.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts b/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts index 8d8db96210f..d2db768c300 100644 --- a/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts +++ b/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts @@ -213,4 +213,68 @@ describe('Set Filter dropdown opened before data arrives (AG-17369)', () => { expect(scenarioA.uiValues).toEqual(['one']); expect(scenarioA.displayedRowCount).toBe(1); }); + + // The reporter and QA (plnkr 41sqYmiFjrSjJN3S) drive the filter through grid-level `api.setFilterModel` — a + // different entry point from the `api.setColumnFilterModel` the tests above exercise (grid-level queues the + // model and replays it against the still-empty client-side row model while inference is pending) — and reuse + // ONE `columnDefs` object across a destroy/recreate "Reset": Scenario A on the cold first grid, Scenario B on + // the grid recreated with that same column def. Opening the dropdown before data must remain observational. + async function runViaGridApi( + api: GridApi, + openBeforeData: boolean + ): Promise<{ ticked: (string | null)[] | null; modelValues: string[] | undefined; displayedRowCount: number }> { + api.setFilterModel({ value: { filterType: 'set', values: ['one'] } }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + if (openBeforeData) { + await openDropdown(api); + api.hideColumnFilter(); + await asyncSetTimeout(0); + } + + api.applyTransaction({ add: [{ value: 'one' }, { value: 'two' }] }); + await asyncSetTimeout(0); + + await openDropdown(api); + const setFilter = await getSetFilter(api); + return { + ticked: setFilter.getModelFromUi()?.values ?? null, + modelValues: api.getColumnFilterModel<{ values: string[] }>('value')?.values, + displayedRowCount: api.getDisplayedRowCount(), + }; + } + + test('grid-level api.setFilterModel converges across a shared-column-def reset (plnkr 41sqYmiFjrSjJN3S)', async () => { + const sharedColumnDefs: GridOptions['columnDefs'] = [{ field: 'value', filter: 'agSetColumnFilter' }]; + + const apiA = gridsManager.createGrid('qa-grid', { + columnDefs: sharedColumnDefs, + defaultColDef: { floatingFilter: true }, + rowData: [], + }); + await asyncSetTimeout(0); + const scenarioA = await runViaGridApi(apiA, false); + + apiA.destroy(); + await asyncSetTimeout(0); + + const apiB = gridsManager.createGrid('qa-grid', { + columnDefs: sharedColumnDefs, + defaultColDef: { floatingFilter: true }, + rowData: [], + }); + await asyncSetTimeout(0); + const scenarioB = await runViaGridApi(apiB, true); + + // Opening the dropdown before data is observational: B converges on A. + expect(scenarioB.ticked).toEqual(scenarioA.ticked); + expect(scenarioB.modelValues).toEqual(scenarioA.modelValues); + expect(scenarioB.displayedRowCount).toBe(scenarioA.displayedRowCount); + + // The converged state applies the reporter's set model — one row, only 'one' ticked. + expect(scenarioA.ticked).toEqual(['one']); + expect(scenarioA.modelValues).toEqual(['one']); + expect(scenarioA.displayedRowCount).toBe(1); + }); }); From 5a1a8a8183549f0b3c3aaa7fde48e7cee6d996e0 Mon Sep 17 00:00:00 2001 From: David Skewis Date: Wed, 19 Aug 2026 11:52:09 +0100 Subject: [PATCH 07/11] AG-15299: prepare line-height call sites for absolute px values (#14782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AG-15299: pin line-heights at call sites that misuse the type scale Only 19 of the 65 `line-height: var(--text-lh-*)` call sites in this repo pair a line-height token with its matching `--text-fs-*`. The rest apply a token to a different, inherited or fluid font size, so changing the tokens to absolute px — which is what AG-15299 asks for — would silently rewrite them. Give those sites the value they render today, so they no longer depend on the token's numeric value: - `--text-lh-*` used with a non-matching font size: replaced with the px value it computes to now (28px, 35px, 24px, 21px, 20px, 14px, 55px, 43px). - `.heroHeading` in BryntumCampaign keeps a ratio: its font-size is `clamp(48px, 5.5vw, 72px)` at the large breakpoint, so no fixed px line-height can track it. - `ContactResultPage .heroHeading` steps 36px -> 48px across a breakpoint, so its line-height steps with it. - Three sites needed a line-height added to a descendant. A unitless line-height is inherited as a ratio and re-multiplies per font-size; a px line-height is inherited as a fixed length. `campaigns .quote blockquote` and the docs intro `code` element previously re-multiplied and now say what they mean. Verified by measuring computed font-size and line-height for every text element across 20 pages, before and after: of 9,445 elements compared, 6 changed — the intended 14px 19.6px -> 20px rounding, max height delta 0.41px. Everything else is byte-identical. * AG-15299: split the inherited line-height ratio out of the type scale `--text-lh-base` is doing two incompatible jobs. `_typography.scss` pairs it with `--text-fs-base` on one element, but `_base.scss` also sets it on `html`, where it is the document-wide default that every element without its own line-height inherits. A unitless line-height re-multiplies against each descendant's own font-size; an absolute px one does not. That single line is what makes AG-15299 dangerous. Measured over 20 pages: turning the eight scale tokens into px changes 6,957 of 9,637 elements, because everything inheriting from `html` collapses onto one fixed length — 40px headings drop from a 48px to a 24px line box, 12px text jumps from 16.8px to 22px, and the largest element height shift is 167px. Introduce `--text-lh-ratio-base` for the consumers that need a ratio (the `html` default and the form elements, whose font-size comes from the user agent) and leave the `--text-lh-` tokens for paired use. Re-measured with the same token flip on top: 419 of 9,637 elements change instead of 6,957, a 94% reduction, and the residue is 13 rule patterns where a scale token is used on a re-sized element. Inert on its own: `--text-lh-ratio-base` is 1.4, the same value `--text-lh-base` has today, so nothing renders differently until the tokens actually change. * AG-15299: reference the line-height token matching each font-size The previous commit pinned the mismatched call sites to the px value they rendered, which kept the pixels identical but dropped the design-system indirection at 13 sites. Point each one at the `--text-lh-*` belonging to its own `--text-fs-*` instead, so the scale stays a single source of truth. This is a deliberate visual change: those sites were referencing a token from a different step, so honouring the pairing moves them onto that step's ratio. Measured over 20 pages, 62 of 11,478 elements move: - 21 elements at 20px: 28px -> 24px line box (`--text-lh-base` -> `--text-lh-lg`), covering the campaign eyebrows, hero body, quote blockquote and the docs intro paragraph. - 10 elements at 32px: 35.2px -> 48px (`--text-lh-3xl` -> `--text-lh-2xl`) on the three campaign headings. The largest element grows 25.6px. - 5 elements at 16px: 24px -> 22.4px (`--text-lh-2xl` -> `--text-lh-base`) on the homepage and pricing quotes. The remainder is reflow around those changes plus known homepage animation jitter. Four sites keep a literal value because no token applies: - `BryntumCampaign .heroHeading` — `clamp(48px, 5.5vw, 72px)`, so the line box has to be a ratio. - `LicenseSetup .licencePlaceholder` (15px), `ContactResultPage .heroHeading` (36px/48px) and `license-pricing .price` (46px) declare raw font sizes that are not on the scale, so there is no matching token to pair with. A px line-height is what gives those an integral line box. The docs intro `code` was `font-size: 0.8em` of a 20px parent, i.e. exactly `--text-fs-base`; stating that makes the pairing explicit and survives the token change. * AG-15299: name the size-agnostic line-heights as ratios --- .../ag-grid-docs/public/styles/bryntum-demo.css | 2 +- .../BryntumCampaign.module.scss | 15 ++++++++------- .../components/docs/components/Header.module.scss | 2 +- .../comparison/ComparisonTable.module.scss | 2 +- .../components/LicenseSetup.module.scss | 4 ++-- .../src/components/quotes/Quotes.module.scss | 2 +- .../src/pages-styles/campaigns.module.scss | 3 ++- .../src/pages-styles/homepage.module.scss | 4 ++-- .../AutomatedExampleDebug.module.scss | 2 +- .../components/changelog/changelog.module.scss | 2 +- .../contact-form/ContactResultPage.module.scss | 5 +++-- .../license-pricing/Licenses.module.scss | 6 +++--- .../license-pricing/SocialProof.module.scss | 2 +- .../license-pricing/license-pricing.module.scss | 12 ++++++------ .../components/major-table/MajorTable.module.scss | 2 +- .../src/components/page-styles/docs.module.scss | 5 +++-- .../components/policies/policyPage.module.scss | 2 +- .../src/components/roadmap/Roadmap.module.scss | 2 +- .../theme-builder/ParamSearchSelector.tsx | 4 ++-- .../src/design-system/_base.scss | 3 ++- .../src/design-system/_root.scss | 11 +++++++++-- .../src/design-system/components/_containers.scss | 2 +- .../src/design-system/core/_mixins.scss | 2 +- .../design-system/elements/_form-elements.scss | 4 ++-- 24 files changed, 56 insertions(+), 44 deletions(-) diff --git a/documentation/ag-grid-docs/public/styles/bryntum-demo.css b/documentation/ag-grid-docs/public/styles/bryntum-demo.css index 7a92a4c2518..13a0b1cda74 100644 --- a/documentation/ag-grid-docs/public/styles/bryntum-demo.css +++ b/documentation/ag-grid-docs/public/styles/bryntum-demo.css @@ -21,7 +21,7 @@ margin: 0 0 8px; font-size: var(--text-fs-lg); font-weight: var(--text-semibold); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-util-brand-600); } diff --git a/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss b/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss index a77d192800c..ce735c132eb 100644 --- a/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss +++ b/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss @@ -85,7 +85,7 @@ margin: 0 0 $spacing-size-2; font-size: var(--text-fs-lg); font-weight: var(--text-semibold); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-util-brand-600); #{$selector-darkmode} & { @@ -100,7 +100,8 @@ .heroHeading { font-size: 48px; - line-height: var(--text-lh-3xl); + // Ratio, not px: the font-size is fluid at the large breakpoint, so the line box must scale with it. + line-height: 1.1; color: var(--color-white); margin: 0 0 $spacing-size-4; @@ -111,7 +112,7 @@ .heroBody { font-size: var(--text-fs-lg); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-white); opacity: 0.92; margin-bottom: $spacing-size-8; @@ -248,7 +249,7 @@ .textMediaCopy { h2 { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -372,7 +373,7 @@ .partnershipHeading { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -466,7 +467,7 @@ .miniDemoCopy { h2 { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -618,7 +619,7 @@ .exampleCardDescription { margin: $spacing-size-1 0 0; font-size: var(--text-fs-sm); - line-height: var(--text-lh-base); + line-height: var(--text-lh-sm); color: var(--color-fg-secondary); } diff --git a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss index 0f0089b4172..8c9aae9e4be 100644 --- a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss +++ b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss @@ -58,7 +58,7 @@ justify-self: start; font-size: var(--text-fs-sm); font-weight: var(--text-regular); - line-height: var(--text-lh-base); + line-height: var(--text-lh-sm); color: var(--color-text-tertiary); } diff --git a/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss b/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss index 3fb887c2b38..375f1999f98 100644 --- a/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss +++ b/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss @@ -55,7 +55,7 @@ .headerTitle { font-size: var(--text-fs-lg); font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-fg-primary); @media screen and (max-width: $breakpoint-landing-page-medium) { diff --git a/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss b/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss index 3f19cfebf79..7f6396af839 100644 --- a/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss +++ b/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss @@ -26,7 +26,7 @@ word-break: break-all; font-family: var(--text-monospace-font-family); font-size: 15px; - line-height: var(--text-lh-base); + line-height: 21px; } .license { @@ -142,7 +142,7 @@ td { padding: 0; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); font-weight: var(--text-bold); &:not(:last-child) { diff --git a/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss b/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss index 21295b76cd4..ff54296934c 100644 --- a/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss +++ b/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss @@ -56,7 +56,7 @@ flex-direction: column; justify-content: space-between; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); border: 1px solid var(--color-border-secondary); margin: -1px 0 0 -1px; padding: 24px; diff --git a/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss b/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss index a4eab679208..bf5b8a1b146 100644 --- a/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss @@ -419,7 +419,7 @@ justify-content: space-between; width: 33.33%; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); padding: $spacing-size-10; @media screen and (max-width: $breakpoint-pricing-medium) { @@ -437,6 +437,7 @@ blockquote { font-size: var(--text-fs-lg); + line-height: var(--text-lh-lg); font-weight: var(--text-semibold); margin-bottom: $spacing-size-6; color: var(--color-fg-primary); diff --git a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss index 62134789fc3..51aca2c2416 100644 --- a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss @@ -69,7 +69,7 @@ body { h1, h2 { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-white); } @@ -343,7 +343,7 @@ body { > div { text-align: center; margin-bottom: $spacing-size-10; - list-style: var(--text-lh-tight); + list-style: var(--text-lh-ratio-tight); @media screen and (min-width: $breakpoint-sponsorship-large) { text-align: unset; diff --git a/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss b/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss index 06be83b32e8..9cb483cd860 100644 --- a/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss +++ b/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss @@ -67,7 +67,7 @@ $z-index-debug-panel: $z-index-debug-canvas + 200; button:not([class^='ag-']) { padding: $spacing-size-1; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); } } diff --git a/external/ag-website-shared/src/components/changelog/changelog.module.scss b/external/ag-website-shared/src/components/changelog/changelog.module.scss index 213ff829204..69192e8ff0a 100644 --- a/external/ag-website-shared/src/components/changelog/changelog.module.scss +++ b/external/ag-website-shared/src/components/changelog/changelog.module.scss @@ -58,7 +58,7 @@ .searchExplainer, label.searchBreakingText { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); @media screen and (max-width: $breakpoint-changelog-pipeline-large) { display: none; diff --git a/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss b/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss index fb377bc59c5..1bc0543e1fb 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss +++ b/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss @@ -43,13 +43,14 @@ .heroHeading { font-size: 36px; font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: 43px; margin: 0; color: var(--color-fg-primary); letter-spacing: -1px; @media screen and (min-width: $breakpoint-docs-nav-medium) { font-size: 48px; + line-height: 58px; } } @@ -191,7 +192,7 @@ .heading { font-size: var(--text-fs-3xl); font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); margin: 0; color: var(--color-fg-primary); diff --git a/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss b/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss index 19612e72fdc..176985b4da2 100644 --- a/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss @@ -72,7 +72,7 @@ p.name { font-size: 46px; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); margin-top: $spacing-size-1; margin-bottom: $spacing-size-2; @@ -136,7 +136,7 @@ p.name { height: 52px; padding-bottom: $spacing-size-3; font-size: 30px; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); font-weight: var(--text-bold); letter-spacing: -0.05em; @@ -164,7 +164,7 @@ p.name { margin-bottom: $spacing-size-4; p { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } diff --git a/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss b/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss index 3e7d06c44f6..f0fa4e91891 100644 --- a/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss @@ -130,7 +130,7 @@ justify-content: space-between; width: 50%; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); @media screen and (max-width: $breakpoint-pricing-medium) { width: 100%; diff --git a/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss b/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss index a4105547b46..e385eac39b0 100644 --- a/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss @@ -76,7 +76,7 @@ gap: $spacing-size-2 $spacing-size-4; margin: 0 auto; margin-top: $spacing-size-12; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); max-width: 600px; padding: $spacing-size-3; border: 1px solid var(--color-border-primary); @@ -182,7 +182,7 @@ } p { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } @@ -242,7 +242,7 @@ p { font-weight: var(--text-bold); - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); } p:first-child { @@ -266,7 +266,7 @@ .price { font-size: 46px !important; // !important for font-size weirdness - line-height: var(--text-lh-tight); + line-height: 55px; font-weight: var(--text-bold); letter-spacing: -0.05em; } @@ -308,7 +308,7 @@ } ul { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } li:not(:last-child) { @@ -465,7 +465,7 @@ } .trialLicenceHeader { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } .trialLicenceCopyItem { diff --git a/external/ag-website-shared/src/components/major-table/MajorTable.module.scss b/external/ag-website-shared/src/components/major-table/MajorTable.module.scss index 24f89ec4a6a..87f4ec739bb 100644 --- a/external/ag-website-shared/src/components/major-table/MajorTable.module.scss +++ b/external/ag-website-shared/src/components/major-table/MajorTable.module.scss @@ -21,7 +21,7 @@ td { padding: $spacing-size-4 $spacing-size-2; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } a span { diff --git a/external/ag-website-shared/src/components/page-styles/docs.module.scss b/external/ag-website-shared/src/components/page-styles/docs.module.scss index 0df2ed88fc8..2386baf9942 100644 --- a/external/ag-website-shared/src/components/page-styles/docs.module.scss +++ b/external/ag-website-shared/src/components/page-styles/docs.module.scss @@ -180,10 +180,11 @@ blockquote:where(:not(:last-child)) { font-size: var(--text-fs-lg); margin-bottom: $spacing-size-8; color: var(--color-text-secondary); - line-height: var(--text-lh-sm); + line-height: var(--text-lh-lg); :global(code) { - font-size: 0.8em; + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); } } diff --git a/external/ag-website-shared/src/components/policies/policyPage.module.scss b/external/ag-website-shared/src/components/policies/policyPage.module.scss index 53c88a1b7c2..c6d36f19ee9 100644 --- a/external/ag-website-shared/src/components/policies/policyPage.module.scss +++ b/external/ag-website-shared/src/components/policies/policyPage.module.scss @@ -30,7 +30,7 @@ nav li:not(:last-child) { margin-bottom: $spacing-size-2; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } } diff --git a/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss b/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss index cd85f184856..41f8a42cb41 100644 --- a/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss +++ b/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss @@ -49,7 +49,7 @@ h1, h2 { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-text-brand-primary); #{$selector-darkmode} & { diff --git a/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx b/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx index fe1610429c3..63f1b62e66b 100644 --- a/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx +++ b/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx @@ -354,11 +354,11 @@ const ItemContent = styled('div')` const ItemLabel = styled('div')` font-weight: var(--text-semibold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); `; const ItemDocs = styled('div')` font-size: var(--text-fs-xs); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-xs); color: var(--color-fg-secondary); `; diff --git a/external/ag-website-shared/src/design-system/_base.scss b/external/ag-website-shared/src/design-system/_base.scss index 3870dffa8e9..9a5e11b3803 100644 --- a/external/ag-website-shared/src/design-system/_base.scss +++ b/external/ag-website-shared/src/design-system/_base.scss @@ -14,7 +14,8 @@ html { font-family: var(--text-font-family); font-size: var(--text-fs-base); - line-height: var(--text-lh-base); + // Ratio: every element without its own line-height inherits this and must re-multiply per font-size. + line-height: var(--text-lh-ratio-base); font-weight: var(--text-regular); background-color: var(--color-bg-primary); color: var(--color-text-primary); diff --git a/external/ag-website-shared/src/design-system/_root.scss b/external/ag-website-shared/src/design-system/_root.scss index 2c4a385c9bf..a61337f6a60 100644 --- a/external/ag-website-shared/src/design-system/_root.scss +++ b/external/ag-website-shared/src/design-system/_root.scss @@ -61,8 +61,15 @@ --text-semibold: 600; --text-bold: 700; - --text-lh-tight: 1.2; - --text-lh-ultra-tight: 1; + // Ratios: a length is inherited as-is, so use these wherever the font-size is inherited, fluid or + // unknown. --text-lh- is paired, and valid only alongside its own --text-fs-. + --text-lh-ratio-base: 1.4; + --text-lh-ratio-tight: 1.2; + --text-lh-ratio-ultra-tight: 1; + + // Deprecated: ag-charts and ag-studio still use these names. Remove once both have migrated. + --text-lh-tight: var(--text-lh-ratio-tight); + --text-lh-ultra-tight: var(--text-lh-ratio-ultra-tight); --text-fs-2xs: 10px; --text-lh-2xs: 1.2; diff --git a/external/ag-website-shared/src/design-system/components/_containers.scss b/external/ag-website-shared/src/design-system/components/_containers.scss index c777f9894b6..767b35ca71a 100644 --- a/external/ag-website-shared/src/design-system/components/_containers.scss +++ b/external/ag-website-shared/src/design-system/components/_containers.scss @@ -66,7 +66,7 @@ position: relative; display: inline-block; padding: ($spacing-size-2 + $spacing-size-1) $spacing-size-1; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-fg-tertiary); transition: color $transition-default-timing; cursor: pointer; diff --git a/external/ag-website-shared/src/design-system/core/_mixins.scss b/external/ag-website-shared/src/design-system/core/_mixins.scss index 2678d9be49a..379d3722fb3 100644 --- a/external/ag-website-shared/src/design-system/core/_mixins.scss +++ b/external/ag-website-shared/src/design-system/core/_mixins.scss @@ -28,7 +28,7 @@ td { padding: 0; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); font-weight: var(--text-bold); &:not(:last-child) { diff --git a/external/ag-website-shared/src/design-system/elements/_form-elements.scss b/external/ag-website-shared/src/design-system/elements/_form-elements.scss index ada25385045..6ec9f2be6bb 100644 --- a/external/ag-website-shared/src/design-system/elements/_form-elements.scss +++ b/external/ag-website-shared/src/design-system/elements/_form-elements.scss @@ -64,7 +64,7 @@ input[type='number'], textarea { &#{$selector-exclude-grid} { padding: math.div(6em, 16) math.div(12em, 16); - line-height: var(--text-lh-base); + line-height: var(--text-lh-ratio-base); border-radius: var(--radius-sm); border: 1px solid var(--color-input-border); background-color: var(--color-bg-primary); @@ -234,7 +234,7 @@ select#{$selector-exclude-grid} { appearance: none; padding: math.div(6em, 16) math.div(36em, 16) math.div(6em, 16) math.div(12em, 16); font-size: var(--text-fs-base); - line-height: var(--text-lh-base); + line-height: var(--text-lh-ratio-base); border-radius: var(--radius-sm); background-color: var(--color-bg-primary); background-image: var(--svg-chevron-down); From 9c34e43fc62c9f9cd6158c837858c652c5519413 Mon Sep 17 00:00:00 2001 From: Victor Musienko <8777372+sdwvit@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:53:27 +0100 Subject: [PATCH 08/11] Load the example runner script in the vanilla JS template (#14901) * Load the example runner script in the vanilla JS template * Use the bundled example runner in exported vanilla JS examples * Call setUpPage in the vanilla JS example template --- .../components/FrameworkTemplate.astro | 1 + .../framework-templates/JavascriptTemplate.astro | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro index 85b2f700593..0c74b5a11af 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro @@ -108,6 +108,7 @@ const shouldShowTemplate = (framework: InternalFramework) => { extras={extras} headFragment={headFragment} usesMathRandom={usesMathRandom} + transpileInBrowser={transpileInBrowser} nonce={nonce} > {addInitMessageScript && ( diff --git a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro index 79569f6c2fb..061e6939f1a 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro @@ -15,6 +15,7 @@ interface Props { extraStyles?: string; extras?: string[]; usesMathRandom?: boolean; + transpileInBrowser?: boolean; nonce?: string; } @@ -33,6 +34,7 @@ const { extraStyles, extras, usesMathRandom, + transpileInBrowser, nonce, } = Astro.props as Props; @@ -50,7 +52,7 @@ import { getChartsEnterpriseScriptPath, getGridLocaleScriptPath, } from '@utils/gridLibraryPaths'; -import { ExampleRunnerClient } from './lib/ExampleRunnerClient'; +import { ExampleRunnerCall, ExampleRunnerClient } from './lib/ExampleRunnerClient'; import { SeedRandom } from './lib/SeedRandom'; const siteUrl = Astro.site ? pathJoin(Astro.site, SITE_BASE_URL) : '/'; @@ -88,14 +90,9 @@ const gridScriptLocalePath = getCacheBustingUrl(getGridLocaleScriptPath(siteUrl) > window.__basePath = appLocation; - { - usesMathRandom && ( - <> - - - - ) - } + + + {usesMathRandom && } {isIntegratedCharts && From b422a3818f8a8c6a303fe9d41a4070cc21633537 Mon Sep 17 00:00:00 2001 From: AgGitDeployment <80415517+AgGitDeployment@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:17:46 +0100 Subject: [PATCH 09/11] Merge from latest. (#14902) --- .env | 2 +- community-modules/locale/package.json | 2 +- community-modules/styles/package.json | 2 +- documentation/ag-grid-docs/package.json | 12 ++++++------ documentation/update-algolia-indices/package.json | 2 +- package.json | 2 +- packages/ag-grid-angular/package.json | 6 +++--- .../projects/ag-grid-angular/package.json | 4 ++-- packages/ag-grid-community/package.json | 4 ++-- packages/ag-grid-community/src/version.ts | 2 +- packages/ag-grid-enterprise/package.json | 6 +++--- packages/ag-grid-enterprise/src/version.ts | 2 +- packages/ag-grid-react/package.json | 6 +++--- packages/ag-grid-vue3/package.json | 4 ++-- packages/ag-stack/package.json | 2 +- packages/ag-stack/src/version.ts | 2 +- .../package.json | 2 +- plugins/ag-grid-generate-example-files/package.json | 4 ++-- plugins/ag-grid-task-autogen/package.json | 2 +- testing/accessibility/package.json | 8 ++++---- testing/ag-test-utils/package.json | 2 +- testing/angular-tests/package.json | 6 +++--- testing/behavioural/package.json | 8 ++++---- testing/behavioural/src/version.ts | 2 +- testing/csp/package.json | 2 +- testing/module-size-angular/package.json | 8 ++++---- testing/module-size/package.json | 8 ++++---- testing/public-recipes/e2e/package.json | 4 ++-- testing/typedoc-links/package.json | 2 +- testing/vue3-tests/package.json | 8 ++++---- 30 files changed, 63 insertions(+), 63 deletions(-) diff --git a/.env b/.env index 0029f92fa0c..e76ed994d17 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ # Production Build -BUILD_GRID_VERSION=36.1.0-beta.20260818.1531 +BUILD_GRID_VERSION=36.1.0-beta.20260819.1156 BUILD_CHARTS_VERSION=14.1.0-beta.20260816 ENV=local NX_BATCH_MODE=true diff --git a/community-modules/locale/package.json b/community-modules/locale/package.json index 7919b51d569..af26670344b 100644 --- a/community-modules/locale/package.json +++ b/community-modules/locale/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/locale", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Localisation Module for AG Grid, providing translations in 31 languages.", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/community-modules/styles/package.json b/community-modules/styles/package.json index 3efb25a9855..210521c516c 100644 --- a/community-modules/styles/package.json +++ b/community-modules/styles/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/styles", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Styles and Themes", "main": "_index.scss", "files": [ diff --git a/documentation/ag-grid-docs/package.json b/documentation/ag-grid-docs/package.json index c1f15c312bf..dd93934c6db 100644 --- a/documentation/ag-grid-docs/package.json +++ b/documentation/ag-grid-docs/package.json @@ -2,7 +2,7 @@ "name": "ag-grid-docs", "description": "Documentation for AG Grid", "type": "module", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "repository": { "type": "git", "url": "https://github.com/ag-grid/ag-grid.git" @@ -59,11 +59,11 @@ "ag-charts-types": "14.1.0-beta.20260816", "ag-charts-react": "14.1.0-beta.20260816", "ag-charts-vue3": "14.1.0-beta.20260816", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-react": "36.1.0-beta.20260818.1531", - "ag-grid-vue3": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-react": "36.1.0-beta.20260819.1156", + "ag-grid-vue3": "36.1.0-beta.20260819.1156", "algoliasearch": "^5.51.0", "astro": "6.1.9", "cheerio": "^1.0.0", diff --git a/documentation/update-algolia-indices/package.json b/documentation/update-algolia-indices/package.json index c37a0d91a27..8230324f5f3 100644 --- a/documentation/update-algolia-indices/package.json +++ b/documentation/update-algolia-indices/package.json @@ -1,6 +1,6 @@ { "name": "update-algolia-indices", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Update algolia indices", "main": "src/index.ts", "type": "module", diff --git a/package.json b/package.json index 8aefa469df2..921810909b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "license": "MIT", "scripts": { "compressVideo": "tsx external/ag-website-shared/scripts/compress-video", diff --git a/packages/ag-grid-angular/package.json b/packages/ag-grid-angular/package.json index 1f7070c811f..84f607e65dc 100644 --- a/packages/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Angular Component", "scripts": { "clean": "rimraf dist", @@ -15,7 +15,7 @@ "module": "./dist/ag-grid-angular/fesm2022/ag-grid-angular.mjs", "typings": "./dist/ag-grid-angular/index.d.ts", "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@angular/animations": "^20.3.25", "@angular/common": "^20.3.25", "@angular/compiler": "^20.3.25", @@ -27,7 +27,7 @@ "zone.js": "~0.15.1" }, "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@angular/build": "^20.3.25", "@angular/cli": "^20.3.25", "@angular/forms": "^20.3.25", diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/package.json b/packages/ag-grid-angular/projects/ag-grid-angular/package.json index d6b200cacfe..ee3d1dbf91c 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/projects/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Angular Component", "license": "MIT", "peerDependencies": { @@ -8,7 +8,7 @@ "@angular/core": ">= 20.0.0" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "tslib": "^2.8.1" }, "repository": { diff --git a/packages/ag-grid-community/package.json b/packages/ag-grid-community/package.json index 58a821eb543..b61dd15300b 100644 --- a/packages/ag-grid-community/package.json +++ b/packages/ag-grid-community/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-community", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -119,7 +119,7 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-stack": "36.1.0-beta.20260818.1531", + "ag-stack": "36.1.0-beta.20260819.1156", "ag-charts-types": "14.1.0-beta.20260816" }, "devDependencies": { diff --git a/packages/ag-grid-community/src/version.ts b/packages/ag-grid-community/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-grid-community/src/version.ts +++ b/packages/ag-grid-community/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/packages/ag-grid-enterprise/package.json b/packages/ag-grid-enterprise/package.json index a67cd1bc779..7a3de81a7ed 100644 --- a/packages/ag-grid-enterprise/package.json +++ b/packages/ag-grid-enterprise/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-enterprise", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -113,8 +113,8 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-stack": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-stack": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "optionalDependencies": { "ag-charts-community": "14.1.0-beta.20260816", diff --git a/packages/ag-grid-enterprise/src/version.ts b/packages/ag-grid-enterprise/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-grid-enterprise/src/version.ts +++ b/packages/ag-grid-enterprise/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/packages/ag-grid-react/package.json b/packages/ag-grid-react/package.json index 41e7b400030..ccf4baffd8e 100644 --- a/packages/ag-grid-react/package.json +++ b/packages/ag-grid-react/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-react", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid React Component", "main": "./dist/package/index.cjs.js", "types": "./dist/types/src/index.d.ts", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/runtime": "^7.29.2", "prop-types": "^15.6.2", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@babel/plugin-proposal-throw-expressions": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@types/react": "~18.3.26", @@ -44,7 +44,7 @@ }, "dependencies": { "prop-types": "^15.8.1", - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/packages/ag-grid-vue3/package.json b/packages/ag-grid-vue3/package.json index e4dd7bec762..369101dbb9d 100644 --- a/packages/ag-grid-vue3/package.json +++ b/packages/ag-grid-vue3/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3", "description": "AG Grid Vue 3 Component", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "author": "Sean Landsman ", "license": "MIT", "files": [ @@ -44,7 +44,7 @@ "build-only:watch": "vite build --watch" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "devDependencies": { "vue": "^3.5.32", diff --git a/packages/ag-stack/package.json b/packages/ag-stack/package.json index 30c38cda478..ade2bbb0208 100644 --- a/packages/ag-stack/package.json +++ b/packages/ag-stack/package.json @@ -1,6 +1,6 @@ { "name": "ag-stack", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/packages/ag-stack/src/version.ts b/packages/ag-stack/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-stack/src/version.ts +++ b/packages/ag-stack/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/plugins/ag-grid-generate-code-reference-files/package.json b/plugins/ag-grid-generate-code-reference-files/package.json index 153bb562d4d..4a3e3498196 100644 --- a/plugins/ag-grid-generate-code-reference-files/package.json +++ b/plugins/ag-grid-generate-code-reference-files/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-generate-code-reference-files", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "ag-shared": "0.0.1", diff --git a/plugins/ag-grid-generate-example-files/package.json b/plugins/ag-grid-generate-example-files/package.json index 01d7949abbe..64e27dcb00a 100644 --- a/plugins/ag-grid-generate-example-files/package.json +++ b/plugins/ag-grid-generate-example-files/package.json @@ -1,10 +1,10 @@ { "name": "ag-grid-generate-example-files", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "ag-shared": "0.0.1", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "glob": "^11.1.0", "typescript": "~5.8.3", "cheerio": "^1.2.0", diff --git a/plugins/ag-grid-task-autogen/package.json b/plugins/ag-grid-task-autogen/package.json index d940fb59366..a351eac61dd 100644 --- a/plugins/ag-grid-task-autogen/package.json +++ b/plugins/ag-grid-task-autogen/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-task-autogen", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "@nx/devkit": "20.8.4", diff --git a/testing/accessibility/package.json b/testing/accessibility/package.json index 895d1b6f6e9..0c04e5b0f18 100644 --- a/testing/accessibility/package.json +++ b/testing/accessibility/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-accessibility", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "download-examples": "curl --retry 5 -retry-all-errors https://grid-staging.ag-grid.com/debug/all-examples.json > ./all-examples.json", "download-examples-local": "curl https://localhost:4610/debug/all-examples.json > ./all-examples.json", @@ -18,9 +18,9 @@ "@angular/platform-browser": "^20.3.25", "@angular/platform-browser-dynamic": "^20.3.25", "@angular/router": "^20.3.25", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "rxjs": "~7.8.2", diff --git a/testing/ag-test-utils/package.json b/testing/ag-test-utils/package.json index 5229f2aaaff..135627349e2 100644 --- a/testing/ag-test-utils/package.json +++ b/testing/ag-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "ag-test-utils", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "type": "module", "description": "Harnesses and assertions shared by the behavioural suite: grid lifecycle, GridRows/GridColumns snapshots, DOM widgets and polyfills. Test-only; never published and never imported by grid source.", diff --git a/testing/angular-tests/package.json b/testing/angular-tests/package.json index fb07dac7b57..defca8ebe6c 100644 --- a/testing/angular-tests/package.json +++ b/testing/angular-tests/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular-tests", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "scripts": { "test:e2e": "jest --no-cache" @@ -11,8 +11,8 @@ "@angular/core": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/platform-browser-dynamic": "^21.0.0", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", "rxjs": "~7.8.2", "tslib": "^2.8.1", "zone.js": "~0.15.0" diff --git a/testing/behavioural/package.json b/testing/behavioural/package.json index 94325dffa9a..6e23aab0b1b 100644 --- a/testing/behavioural/package.json +++ b/testing/behavioural/package.json @@ -1,6 +1,6 @@ { "name": "ag-behavioural-testing", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "description": "Behavioural unit testing for ag-Grid", "dependencies": { @@ -8,9 +8,9 @@ }, "type": "module", "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-react": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-react": "36.1.0-beta.20260819.1156", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@testing-library/dom": "^10.4.1", diff --git a/testing/behavioural/src/version.ts b/testing/behavioural/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/testing/behavioural/src/version.ts +++ b/testing/behavioural/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/testing/csp/package.json b/testing/csp/package.json index 858522534b1..8f3537b6d13 100644 --- a/testing/csp/package.json +++ b/testing/csp/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-csp", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "CSP testing for AG Grid", "main": "index.js", "scripts": {}, diff --git a/testing/module-size-angular/package.json b/testing/module-size-angular/package.json index b3dbb16472f..6bbc8e7b993 100644 --- a/testing/module-size-angular/package.json +++ b/testing/module-size-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-module-size-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "ng": "ng", "start": "ng serve", @@ -20,9 +20,9 @@ "@angular/platform-browser": "^20.3.25", "@angular/platform-browser-dynamic": "^20.3.25", "@angular/router": "^20.3.25", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "rxjs": "~7.8.2", diff --git a/testing/module-size/package.json b/testing/module-size/package.json index 9ff0f60a1e3..05d875f517a 100644 --- a/testing/module-size/package.json +++ b/testing/module-size/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-module-size", "private": true, - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "dev": "vite", "cp-app": "cp ./src/App_Src.tsx ./src/App_AUTO.tsx", @@ -14,9 +14,9 @@ "test:e2e": "run-s \"module-combinations -- {1}\" module-validate --" }, "dependencies": { - "ag-grid-react": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-react": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "ag-shared": "0.0.1", diff --git a/testing/public-recipes/e2e/package.json b/testing/public-recipes/e2e/package.json index 6bb8153cc93..12b3ab4de73 100644 --- a/testing/public-recipes/e2e/package.json +++ b/testing/public-recipes/e2e/package.json @@ -1,12 +1,12 @@ { "name": "ag-grid-public-e2e-testing-recipes", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Public E2E testing recipes for AG Grid", "main": "index.js", "scripts": {}, "license": "MIT", "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "playwright": "^1.59.1", "@playwright/test": "^1.59.1", "@types/node": "^22.15.3" diff --git a/testing/typedoc-links/package.json b/testing/typedoc-links/package.json index 004b65fbb9d..af56c852022 100644 --- a/testing/typedoc-links/package.json +++ b/testing/typedoc-links/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-typedoc-links", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "type": "module", "description": "Validates that AG Grid's published .d.ts JSDoc {@link} references resolve under TypeDoc, reproducing the environment downstream consumers build their API docs in.", diff --git a/testing/vue3-tests/package.json b/testing/vue3-tests/package.json index 69be9d36347..78438ca3652 100644 --- a/testing/vue3-tests/package.json +++ b/testing/vue3-tests/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3-tests", "private": true, - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "type": "module", "scripts": { "dev": "vite", @@ -15,9 +15,9 @@ "dependencies": { "vue": "^3.5.32", "vue-router": "^5.0.6", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-vue3": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-vue3": "36.1.0-beta.20260819.1156", "decimal.js": "^10.6.0" }, "devDependencies": { From 16c0602c84b83dbc9a4cae8c27102dcc46ef8458 Mon Sep 17 00:00:00 2001 From: Tak Tran Date: Wed, 19 Aug 2026 14:06:27 +0100 Subject: [PATCH 10/11] AG-3390 Lint against raw history writes (#14900) * AG-3390 Lint against raw history writes `no-restricted-properties` cannot express this: it resolves the object only for a bare identifier, so it matches `history.replaceState` but not `window.history.replaceState`. Use a `no-restricted-syntax` selector on the callee's property name instead, which catches both. The rule lives in the ag-website-shared config, so it is identical in every consumer of the subrepo and covers the shared components plus the helper's own directory (the helper itself is exempt). The docs config carries the same rule for site-local code. It immediately caught two live cases under public/scripts/, which the original sweep missed by only searching src/: - license-pricing.js replaced the state outright, so clicking the pricing CTA killed back/forward for the rest of the session. - beyond-the-prompt.js pushed a partial state, leaving `index` undefined, which makes the router read every later traversal as a "back" and turns its next push index into NaN. Both are classic scripts served from public/ with no module system, so they cannot import the helper and take inline suppressions instead. * AG-3390 Address review feedback on the history lint Scope the selector to browser history. Matching on the method name alone also rejected any unrelated object with a `pushState`/`replaceState` method - a state machine, a mock, a history-shaped abstraction. It now matches only a `history` receiver, in both the bare and `window`/`globalThis` qualified forms. Verified against all three real forms plus a same-named decoy. Lift the restriction list into the subrepo as eslint.history-rules.mjs and import it from both configs, so the rule has one definition per repo rather than a copy per config that can drift apart. Drop the `.ts`/`.tsx` files glob with it. The public/ scripts that motivated this rule are plain `.js`, so the glob excluded exactly the code most likely to get it wrong. Note `.astro` client scripts stay uncovered either way - there is no eslint-plugin-astro in the toolchain. Advance the index on the modal's pushed entry rather than copying it. Spreading the state alone left the new entry sharing the previous entry's index, and the router derives direction from `index`, so a forward traversal into the modal compared equal and still read as a "back". Mirror what the router writes on its own pushes. --- documentation/ag-grid-docs/eslint.config.mjs | 6 +++++ .../public/scripts/beyond-the-prompt.js | 15 ++++++++++- .../public/scripts/license-pricing.js | 7 +++++- external/ag-website-shared/eslint.config.mjs | 9 +++++++ .../eslint.history-rules.mjs | 25 +++++++++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 external/ag-website-shared/eslint.history-rules.mjs diff --git a/documentation/ag-grid-docs/eslint.config.mjs b/documentation/ag-grid-docs/eslint.config.mjs index e9780fc8c6e..c2736ad77ba 100644 --- a/documentation/ag-grid-docs/eslint.config.mjs +++ b/documentation/ag-grid-docs/eslint.config.mjs @@ -1,6 +1,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks'; import rootESLint from '../../eslint.config.mjs'; +import { noRawHistoryWrites } from '../../external/ag-website-shared/eslint.history-rules.mjs'; export default [ ...rootESLint, @@ -71,4 +72,9 @@ export default [ '@typescript-eslint/no-var-requires': 'off', }, }, + { + rules: { + 'no-restricted-syntax': ['error', ...noRawHistoryWrites], + }, + }, ]; diff --git a/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js b/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js index 390f09956ed..9948c38e5f0 100644 --- a/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js +++ b/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js @@ -193,7 +193,20 @@ // href, so fall back to the current URL rather than pushing null // (which would corrupt the address bar and back/forward behaviour). const sessionUrl = link.getAttribute('href') || location.href; - history.pushState({ btpSession: videoId }, '', sessionUrl); + // Mirror what Astro's ClientRouter writes on its own pushes. It derives traversal + // direction from `state.index`, so the entry needs the router's other bookkeeping + // carried over and an index one past the current entry: leaving `index` off entirely + // turns its next push index into NaN, and reusing the current one makes a forward + // traversal into this entry compare equal and read as a "back". Scroll offsets ride + // along from the spread so returning here restores the position the modal opened at. + // This is a classic script served verbatim from public/, so the shared + // replaceHistoryUrl() helper is not importable here. + // eslint-disable-next-line no-restricted-syntax -- no module system in a public/ script + history.pushState( + { ...history.state, index: (history.state?.index ?? 0) + 1, btpSession: videoId }, + '', + sessionUrl + ); }); } for (const closer of document.querySelectorAll('[data-session-modal-close]')) { diff --git a/documentation/ag-grid-docs/public/scripts/license-pricing.js b/documentation/ag-grid-docs/public/scripts/license-pricing.js index f5b207910e8..821e3644ec2 100644 --- a/documentation/ag-grid-docs/public/scripts/license-pricing.js +++ b/documentation/ag-grid-docs/public/scripts/license-pricing.js @@ -14,7 +14,12 @@ if (target) { event.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); - history.replaceState(null, '', '#pricing'); + // Preserve the existing state: Astro's ClientRouter keeps its history index and + // scroll offsets there, and replacing them breaks back/forward for the whole page. + // Classic script served verbatim from public/, so the shared replaceHistoryUrl() + // helper is not importable here. + // eslint-disable-next-line no-restricted-syntax -- no module system in a public/ script + history.replaceState(history.state, '', '#pricing'); } }); })(); diff --git a/external/ag-website-shared/eslint.config.mjs b/external/ag-website-shared/eslint.config.mjs index 5e12fe374ba..e9b23257f75 100644 --- a/external/ag-website-shared/eslint.config.mjs +++ b/external/ag-website-shared/eslint.config.mjs @@ -3,6 +3,8 @@ import pluginJs from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import { noRawHistoryWrites } from './eslint.history-rules.mjs'; + export default [ { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, @@ -45,4 +47,11 @@ export default [ 'no-undef': 'off', }, }, + { + // The helper itself is the one place a raw write belongs. + ignores: ['src/utils/historyUrl.ts'], + rules: { + 'no-restricted-syntax': ['error', ...noRawHistoryWrites], + }, + }, ]; diff --git a/external/ag-website-shared/eslint.history-rules.mjs b/external/ag-website-shared/eslint.history-rules.mjs new file mode 100644 index 00000000000..1d0f728f737 --- /dev/null +++ b/external/ag-website-shared/eslint.history-rules.mjs @@ -0,0 +1,25 @@ +/** + * Astro's ClientRouter stores its history index and scroll offsets in `history.state`. A raw + * write replaces them, after which its popstate handler either bails out or reads every + * traversal as a "back", silently breaking back/forward for the whole page. + * + * Shared by the ag-website-shared config and each site's own config, so both halves of a + * website enforce the same invariant from one definition. + */ +const message = + 'Use replaceHistoryUrl() from @ag-website-shared/utils/historyUrl - a raw history write discards the router state that back/forward depends on.'; + +const methods = '/^(pushState|replaceState)$/'; + +export const noRawHistoryWrites = [ + // `history.pushState(...)` + { + selector: `CallExpression > MemberExpression.callee[object.name='history'][property.name=${methods}]`, + message, + }, + // `window.history.pushState(...)`, `globalThis.history.pushState(...)` + { + selector: `CallExpression > MemberExpression.callee[object.property.name='history'][property.name=${methods}]`, + message, + }, +]; From 3529aa13bbe2e9a00bd77573d872a0004c047662 Mon Sep 17 00:00:00 2001 From: David Skewis Date: Wed, 19 Aug 2026 14:58:44 +0100 Subject: [PATCH 11/11] AG-3390 sync examples (#14905) * Update .gitrepo parent sha * git subrepo commit external/ag-website-shared subrepo: subdir: "external/ag-website-shared" merged: "33a9edfb59b" upstream: origin: "git@github.com:ag-grid/ag-website-shared.git" branch: "latest" commit: "c7e3b1a2514" git-subrepo: version: "0.4.9" origin: "https://github.com/ingydotnet/git-subrepo" commit: "5e0f401" * git subrepo push external/ag-website-shared subrepo/external/ag-website-shared subrepo: subdir: "external/ag-website-shared" merged: "33a9edfb59b" upstream: origin: "git@github.com:ag-grid/ag-website-shared.git" branch: "latest" commit: "33a9edfb59b" git-subrepo: version: "0.4.9" origin: "https://github.com/ingydotnet/git-subrepo" commit: "5e0f401" --- external/ag-website-shared/.gitrepo | 4 +- .../src/components/demo-page/DemoPage.astro | 110 +++++ .../components/demo-page/DemoPage.module.scss | 375 ++++++++++++++++++ .../src/components/demo-page/types.ts | 27 ++ .../components/search/SearchBox.module.scss | 2 +- .../buildFrameworkRedirectMarkdown.test.ts | 38 ++ .../src/utils/extraCodeSnippets.ts | 7 + 7 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 external/ag-website-shared/src/components/demo-page/DemoPage.astro create mode 100644 external/ag-website-shared/src/components/demo-page/DemoPage.module.scss create mode 100644 external/ag-website-shared/src/components/demo-page/types.ts create mode 100644 external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts diff --git a/external/ag-website-shared/.gitrepo b/external/ag-website-shared/.gitrepo index cdb7485f1f0..89f61683ab1 100644 --- a/external/ag-website-shared/.gitrepo +++ b/external/ag-website-shared/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:ag-grid/ag-website-shared.git branch = latest - commit = e914cbd1b9149f4a849b97f9ce5c4711d0cc38cc - parent = 3aeb2db7e42fd6a7458b5179c0f581b6691ad9be + commit = 33a9edfb59b108a89ecc16aabbc395ca96118713 + parent = 3f6d6ac9e128944c8ec07f6b1c61233dc0d672c3 method = rebase cmdver = 0.4.9 diff --git a/external/ag-website-shared/src/components/demo-page/DemoPage.astro b/external/ag-website-shared/src/components/demo-page/DemoPage.astro new file mode 100644 index 00000000000..4f8e2f8bead --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/DemoPage.astro @@ -0,0 +1,110 @@ +--- +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; + +import styles from './DemoPage.module.scss'; +import type { DemoPageExample, DemoPageHero } from './types'; + +interface Props extends DemoPageHero { + /** Which demo is currently shown; drives the active feature-list item. */ + currentId: string; + /** Every demo the site offers, in the order the feature list shows them. */ + examples: DemoPageExample[]; +} + +const { currentId, examples, eyebrow, title, description, primaryCta, secondaryCta } = Astro.props; +--- + +
+ + + {/* Stands in for the hero and demo card, both hidden at this notice's breakpoint. */} + + +
+
+
+ +
+ + + + +
+
+
+ +{ + /* Each site consuming this component serves the script from its own public folder. + data-astro-rerun: the router swaps in a fresh, unwired stage, so the launch overlay + needs re-binding on each visit rather than only on the first document to load this. */ +} + diff --git a/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss b/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss new file mode 100644 index 00000000000..1975846e9c8 --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss @@ -0,0 +1,375 @@ +@use 'design-system' as *; + +// Wider than most screens, so the demo reads as a full-size app running off the +// edge rather than a shrunken one. +$demo-held-width: 1600px; +$hero-max-width: 480px; // Matches the .hero clamp ceiling. +$demo-column-gap: $spacing-size-8; +$demo-gutter-base: $spacing-size-12; +$demo-gutter-wide: 100px; +// Where the demo first fits between the wide gutters; derived so it tracks its parts. +$breakpoint-demo-fits: $demo-held-width + $hero-max-width + $demo-column-gap + $demo-gutter-wide * 2; + +// Deliberately uncapped, unlike every other page: until the demo fits, the card +// takes the right-hand gutter and runs off the edge of the screen. +.demoPage { + // JS overwrites --demo-height after first paint, so this fallback has to be + // right too or the demo renders taller than the window and snaps. + --demo-chrome: var(--layout-site-header-height); + --demo-height: calc(100vh - var(--demo-chrome)); + + --demo-gutter: var(--layout-horizontal-margins); + + display: flex; + flex-direction: column; + gap: $demo-column-gap; + width: 100%; + padding: $spacing-size-6 var(--demo-gutter); + box-sizing: border-box; + + // Only the slotted mobile notice shows this narrow; give it the space the demo would have filled. + min-height: var(--demo-height); + justify-content: center; + + @media screen and (min-width: $breakpoint-docs-search-medium) { + min-height: 0; + justify-content: flex-start; + } + + @media screen and (min-width: $breakpoint-hero-large) { + --demo-gutter: #{$demo-gutter-base}; + + flex-direction: row; + align-items: stretch; + height: var(--demo-height); + } + + @media screen and (min-width: $breakpoint-demo-fits) { + --demo-gutter: #{$demo-gutter-wide}; + } +} + +// The banner pushes this page down, so its single-line height comes out of the +// space available to the demo. +:global(html[data-show-announcement='true']) .demoPage { + --demo-chrome: calc(var(--layout-site-header-height) + 37px); +} + +.hero { + // Its copy sells the demo and its list switches between examples: nothing to offer + // while no demo is reachable. + display: none; + flex-shrink: 0; + + @media screen and (min-width: $breakpoint-docs-search-medium) { + display: flex; + } + + @media screen and (min-width: $breakpoint-hero-large) { + width: clamp(360px, 34vw, 480px); + align-items: center; + } +} + +.heroInner { + display: flex; + flex-direction: column; + width: 100%; + // Full width while stacked, where the hero has the whole page to itself; only + // capped once it becomes the narrow left column beside the demo. + @media screen and (min-width: $breakpoint-hero-large) { + max-width: 440px; + } +} + +.eyebrow { + margin: 0 0 $spacing-size-3; + font-size: var(--text-fs-lg); + font-weight: var(--text-semibold); + // Brand shades don't flip with the theme, so brand-600 needs a lighter + // substitute on dark backgrounds. + color: var(--color-brand-600); + + #{$selector-darkmode} & { + color: var(--color-brand-300); + } +} + +.title { + margin: 0 0 $spacing-size-5; + font-size: var(--text-fs-3xl); + line-height: var(--text-lh-3xl); + font-weight: var(--text-bold); + letter-spacing: -0.02em; + color: var(--color-fg-primary); +} + +.description { + margin: 0 0 $spacing-size-8; + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); + color: var(--color-fg-secondary); +} + +// Doubles as the demo switcher. A shared rule runs down the left; each item's own +// transparent border overlays it, colouring in when active. +.featureList { + list-style: none; + margin: 0 0 $spacing-size-8; + padding: 0; + border-left: 2px solid var(--color-border-primary); +} + +.featureItem { + display: flex; + flex-direction: column; + gap: $spacing-size-1; + margin-left: -2px; + padding: $spacing-size-3 0 $spacing-size-3 $spacing-size-8; + border-left: 2px solid transparent; + text-decoration: none; + + &:hover .featureTitle { + color: var(--color-brand-500); + } + + #{$selector-darkmode} &:hover .featureTitle { + color: var(--color-brand-300); + } +} + +.featureItemActive { + border-left-color: var(--color-brand-600); + + .featureTitle { + color: var(--color-brand-600); + } + + #{$selector-darkmode} & { + border-left-color: var(--color-brand-300); + + .featureTitle { + color: var(--color-brand-300); + } + } +} + +.featureTitle { + font-size: 18px; + font-weight: var(--text-semibold); + line-height: var(--text-lh-base); + color: var(--color-fg-primary); + transition: color $transition-default-timing; +} + +.featureDescription { + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); + // Undo the global `a` rule's semibold so the title carries the weight. + font-weight: var(--text-regular); + color: var(--color-fg-tertiary); +} + +.ctaButtons { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $spacing-size-3; +} + +.contactButton { + gap: $spacing-size-2; + :global(.icon) { + --icon-size: #{$spacing-size-4}; + } +} + +.demoCard { + position: relative; + // The demo has no usable small-screen form: it is held at $demo-held-width, so a phone + // would show a sliver of it. Hide it until there is room; the page-level mobile notice + // stands in, as one inside this card would centre in the held width and land off-screen. + display: none; + flex: 1 1 auto; + min-width: 0; + height: 70vh; + min-height: 480px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-lg); + overflow: hidden; + + // Same threshold the mobile notice uses to call an example unavailable. + @media screen and (min-width: $breakpoint-docs-search-medium) { + display: block; + } + + @media screen and (min-width: $breakpoint-hero-large) { + height: auto; + + // A clipped demo runs off the screen edge rather than stopping short of it, + // since the gutter would only be dead space beside it. updateCutOff + // (demo-page.js) measures the un-bled width, so this can't oscillate with + // the state that applies it. + &:has([data-cutoff='true']) { + margin-right: calc(-1 * var(--demo-gutter)); + } + } + + // The expanded stage covers the page opaquely, but this card's border and shadow + // would still show behind it. Must follow the bleed above to win on source order. + &:has([data-expanded='true']) { + margin-right: 0; + border-color: transparent; + box-shadow: none; + } +} + +// The demo is a non-interactive preview (viewport `inert`, overlay swallowing +// clicks) until launched, then expands to fill the viewport and becomes live. +.stage { + position: relative; + width: 100%; + height: 100%; + + &[data-expanded='true'] { + position: fixed; + // positionBelowHeader (demo-page.js) writes the sticky header's live + // bottom here, so the header stays visible. + top: var(--stage-top, 0px); + right: 0; + bottom: 0; + left: 0; + // top+bottom own the height; overrides the base height:100%, which would + // make it a full viewport tall and overflow below the header. + height: auto; + box-sizing: border-box; + // Generous top band keeps Minimise off the demo; the remaining three sides + // share one inset so the frame sits evenly, giving the demo the width the + // page gutters would otherwise take. + padding: $spacing-size-16 $spacing-size-6 $spacing-size-6; + // Below the site header (10002) and banner (10005). + z-index: 9999; + background: var(--color-bg-primary); + } +} + +.stageViewport { + width: 100%; + height: 100%; + // A floor, not a cap: the card clips the overflow, so the demo is cut off on + // the right rather than shrinking, and still grows past this when there's room. + min-width: $demo-held-width; +} + +// Expanded: take the real available width, framed like the preview card. +.stage[data-expanded='true'] .stageViewport { + min-width: 0; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +// Each demo's own script sizes itself from the viewport bottom, overshooting this +// container and clipping bottom-pinned content such as the sidebar profile +// switcher. Pin it to the container instead. +.stageViewport > :global(*) { + height: 100% !important; +} + +// Hit layer that, with the `inert` viewport, makes the preview non-interactive. +// Transparent on load so the demo reads crisply; the idle zero-blur filter is what +// lets the hover blur interpolate rather than pop. +.stageOverlay { + position: absolute; + inset: 0; + z-index: 2; + display: none; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + border: none; + cursor: pointer; + background: transparent; + backdrop-filter: blur(0) saturate(1); + -webkit-backdrop-filter: blur(0) saturate(1); + transition: + background $transition-default-timing, + backdrop-filter $transition-default-timing, + -webkit-backdrop-filter $transition-default-timing; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--color-bg-primary) 38%, transparent); + backdrop-filter: blur(5px) saturate(1.05); + -webkit-backdrop-filter: blur(5px) saturate(1.05); + } +} + +// Gate interaction only while the demo is cropped; when it all fits, it is live +// inline with no overlay. +.stage[data-cutoff='true']:not([data-expanded='true']) .stageOverlay { + display: flex; +} + +// Borrows the primary button's tokens so it sits in the site's design language. +.stageHint { + display: inline-flex; + align-items: center; + gap: $spacing-size-2; + padding: 0.5em 1.15em 0.6em; + border-radius: var(--radius-sm); + background-color: var(--color-button-primary-bg); + color: var(--color-button-primary-fg); + border: 1px solid var(--color-button-primary-border); + font-size: var(--text-fs-base); + font-weight: var(--text-bold); + box-shadow: var(--shadow-lg); + // Rises into view on hover/focus of the overlay. + opacity: 0; + transform: translateY($spacing-size-2) scale(0.98); + transition: + opacity $transition-default-timing, + background-color $transition-default-timing, + transform $transition-default-timing; + + .stageOverlay:hover &, + .stageOverlay:focus-visible & { + opacity: 1; + background-color: var(--color-button-primary-bg-hover); + transform: none; + } +} + +.stageHintIcon { + flex-shrink: 0; +} + +.stageMinimise { + position: absolute; + top: $spacing-size-3; + // Matches the stage's side padding so it lines up with the demo's left edge. + left: $spacing-size-6; + z-index: 4; + // Colour, border, padding and states come from the global `button-secondary` + // class in the markup; this only owns placement and icon + label layout. + align-items: center; + gap: $spacing-size-2; + + // Scoped selectors out-specify the global button `display: inline-block`. + .stage:not([data-expanded='true']) & { + display: none; + } + + .stage[data-expanded='true'] & { + display: inline-flex; + } +} + +.stageMinimiseIcon { + flex-shrink: 0; +} diff --git a/external/ag-website-shared/src/components/demo-page/types.ts b/external/ag-website-shared/src/components/demo-page/types.ts new file mode 100644 index 00000000000..bfa7800b744 --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/types.ts @@ -0,0 +1,27 @@ +/** One demo in the page's feature list, which doubles as the switcher between demos. */ +export interface DemoPageExample { + id: string; + title: string; + /** Base-relative page path, e.g. './example'. */ + path: string; + /** Short supporting copy shown beneath the title. */ + description: string; +} + +/** A call to action rendered beneath the feature list. */ +export interface DemoPageCta { + label: string; + href: string; +} + +/** The consuming site's copy for the demo page hero. */ +export interface DemoPageHero { + /** Small uppercase label above the title. */ + eyebrow: string; + /** Main hero heading. */ + title: string; + /** Supporting paragraph beneath the heading. */ + description: string; + primaryCta: DemoPageCta; + secondaryCta: DemoPageCta; +} diff --git a/external/ag-website-shared/src/components/search/SearchBox.module.scss b/external/ag-website-shared/src/components/search/SearchBox.module.scss index 8ebf34a7516..65050df8edc 100644 --- a/external/ag-website-shared/src/components/search/SearchBox.module.scss +++ b/external/ag-website-shared/src/components/search/SearchBox.module.scss @@ -54,7 +54,7 @@ input[type='search'].searchInput { span { text-transform: uppercase; font-weight: 600; - letter-spacing: 1.2pxs; + letter-spacing: 1.2px; color: var(--color-text-secondary); opacity: 0.6; font-size: 13px; diff --git a/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts b/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts new file mode 100644 index 00000000000..6dadacce5da --- /dev/null +++ b/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildFrameworkRedirectMarkdown } from './buildFrameworkRedirectMarkdown'; + +const SITE_ROOT = 'https://www.ag-grid.com/'; + +const DESTINATIONS = [ + { label: 'React', url: '/studio/react/licence-install/' }, + { label: 'Angular', url: '/studio/angular/licence-install/' }, +]; + +describe('buildFrameworkRedirectMarkdown', () => { + const output = buildFrameworkRedirectMarkdown({ + title: 'AG Studio: Licensing', + description: 'Installing Your Licence Key', + heading: 'AG Studio Licensing', + destinations: DESTINATIONS, + siteRoot: SITE_ROOT, + }); + + it('emits the page metadata as frontmatter, then the heading as H1', () => { + expect(output.startsWith('---\n')).toBe(true); + expect(output).toContain('title: "AG Studio: Licensing"'); + expect(output).toContain('description: "Installing Your Licence Key"'); + expect(output).toContain('# AG Studio Licensing'); + }); + + it('spells out every framework destination, since a reader cannot be redirected', () => { + for (const { label, url } of DESTINATIONS) { + expect(output).toContain(`- [${label}](https://www.ag-grid.com${url})`); + } + }); + + it('ends with a single trailing newline', () => { + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); diff --git a/external/ag-website-shared/src/utils/extraCodeSnippets.ts b/external/ag-website-shared/src/utils/extraCodeSnippets.ts index 83d526325a5..1252a93a125 100644 --- a/external/ag-website-shared/src/utils/extraCodeSnippets.ts +++ b/external/ag-website-shared/src/utils/extraCodeSnippets.ts @@ -16,6 +16,9 @@ export const TEAR_DOWN_END = '/** TEAR DOWN END **/'; export const TEST_ID_START = '/** ENABLE AG-TEST-ID START **/'; export const TEST_ID_END = '/** ENABLE AG-TEST-ID END **/'; +export const AI_TELEMETRY_START = '/** AI TELEMETRY START **/'; +export const AI_TELEMETRY_END = '/** AI TELEMETRY END **/'; + export const DARK_MODE_REGEX = getSnippetRegex({ startDelimiter: DARK_MODE_START, endDelimiter: DARK_MODE_END }); export const E2E_THEME_REGEX = getSnippetRegex({ startDelimiter: E2E_THEME_START, endDelimiter: E2E_THEME_END }); export const CONSOLE_LOG_REGEX = getSnippetRegex({ startDelimiter: CONSOLE_LOG_START, endDelimiter: CONSOLE_LOG_END }); @@ -25,6 +28,10 @@ export const DARK_INTEGRATED_REGEX = getSnippetRegex({ }); export const TEAR_DOWN_REGEX = getSnippetRegex({ startDelimiter: TEAR_DOWN_START, endDelimiter: TEAR_DOWN_END }); export const TEST_ID_REGEX = getSnippetRegex({ startDelimiter: TEST_ID_START, endDelimiter: TEST_ID_END }); +export const AI_TELEMETRY_REGEX = getSnippetRegex({ + startDelimiter: AI_TELEMETRY_START, + endDelimiter: AI_TELEMETRY_END, +}); /** * Return a regex that matches a snippet of text between specified delimiters.