diff --git a/documentation/ag-grid-docs/src/content/docs/aggregation/index.mdoc b/documentation/ag-grid-docs/src/content/docs/aggregation/index.mdoc index dfe29024d92..d0f073add7b 100644 --- a/documentation/ag-grid-docs/src/content/docs/aggregation/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/aggregation/index.mdoc @@ -47,7 +47,8 @@ After the grid is initialised aggregations can be applied / retrieved / removed ## Retrieving Aggregated Children The method `rowNode.getAggregatedChildren(colKey)` with Client Side Row Model returns the immediate children that contribute to the aggregation -of a group row. This is useful when implementing custom logic based on aggregated data. +of a group row. This is useful when implementing custom logic based on aggregated data or when +[Editing Group Rows](./grouping-edit/) to update child rows accordingly. - For regular group columns, returns the direct children used for aggregation (respecting `suppressAggFilteredOnly` and `groupAggFiltering` settings). diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/data.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/data.ts new file mode 100644 index 00000000000..16e57e71b63 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/data.ts @@ -0,0 +1,43 @@ +export const getData = () => [ + // Europe - Corporate + { id: 'fr-paris', region: 'Europe', segment: 'Corporate', country: 'France', amount: 30 }, + { id: 'fr-lyon', region: 'Europe', segment: 'Corporate', country: 'France', amount: 30 }, + { id: 'de-berlin', region: 'Europe', segment: 'Corporate', country: 'Germany', amount: 35 }, + { id: 'de-hamburg', region: 'Europe', segment: 'Corporate', country: 'Germany', amount: 25 }, + { id: 'es-madrid', region: 'Europe', segment: 'Corporate', country: 'Spain', amount: 28 }, + { id: 'es-barcelona', region: 'Europe', segment: 'Corporate', country: 'Spain', amount: 32 }, + { id: 'uk-london', region: 'Europe', segment: 'Corporate', country: 'UK', amount: 45 }, + { id: 'uk-manchester', region: 'Europe', segment: 'Corporate', country: 'UK', amount: 35 }, + // Europe - Enterprise + { id: 'it-rome', region: 'Europe', segment: 'Enterprise', country: 'Italy', amount: 40 }, + { id: 'it-milan', region: 'Europe', segment: 'Enterprise', country: 'Italy', amount: 20 }, + { id: 'pl-warsaw', region: 'Europe', segment: 'Enterprise', country: 'Poland', amount: 26 }, + { id: 'pl-krakow', region: 'Europe', segment: 'Enterprise', country: 'Poland', amount: 24 }, + { id: 'nl-amsterdam', region: 'Europe', segment: 'Enterprise', country: 'Netherlands', amount: 38 }, + { id: 'nl-rotterdam', region: 'Europe', segment: 'Enterprise', country: 'Netherlands', amount: 32 }, + // Americas - Corporate + { id: 'us-nyc', region: 'Americas', segment: 'Corporate', country: 'USA', amount: 70 }, + { id: 'us-la', region: 'Americas', segment: 'Corporate', country: 'USA', amount: 30 }, + { id: 'us-austin', region: 'Americas', segment: 'Corporate', country: 'USA', amount: 25 }, + { id: 'us-chicago', region: 'Americas', segment: 'Corporate', country: 'USA', amount: 55 }, + { id: 'ca-toronto', region: 'Americas', segment: 'Corporate', country: 'Canada', amount: 35 }, + { id: 'ca-vancouver', region: 'Americas', segment: 'Corporate', country: 'Canada', amount: 25 }, + // Americas - Enterprise + { id: 'br-sao-paulo', region: 'Americas', segment: 'Enterprise', country: 'Brazil', amount: 30 }, + { id: 'br-rio', region: 'Americas', segment: 'Enterprise', country: 'Brazil', amount: 22 }, + { id: 'mx-tijuana', region: 'Americas', segment: 'Enterprise', country: 'Mexico', amount: 28 }, + { id: 'mx-guadalajara', region: 'Americas', segment: 'Enterprise', country: 'Mexico', amount: 18 }, + { id: 'ar-buenos-aires', region: 'Americas', segment: 'Enterprise', country: 'Argentina', amount: 24 }, + // Asia Pacific - Corporate + { id: 'jp-tokyo', region: 'Asia Pacific', segment: 'Corporate', country: 'Japan', amount: 65 }, + { id: 'jp-osaka', region: 'Asia Pacific', segment: 'Corporate', country: 'Japan', amount: 45 }, + { id: 'au-sydney', region: 'Asia Pacific', segment: 'Corporate', country: 'Australia', amount: 42 }, + { id: 'au-melbourne', region: 'Asia Pacific', segment: 'Corporate', country: 'Australia', amount: 38 }, + // Asia Pacific - Enterprise + { id: 'cn-shanghai', region: 'Asia Pacific', segment: 'Enterprise', country: 'China', amount: 80 }, + { id: 'cn-beijing', region: 'Asia Pacific', segment: 'Enterprise', country: 'China', amount: 70 }, + { id: 'sg-singapore', region: 'Asia Pacific', segment: 'Enterprise', country: 'Singapore', amount: 55 }, + { id: 'kr-seoul', region: 'Asia Pacific', segment: 'Enterprise', country: 'South Korea', amount: 48 }, + { id: 'in-mumbai', region: 'Asia Pacific', segment: 'Enterprise', country: 'India', amount: 36 }, + { id: 'in-bangalore', region: 'Asia Pacific', segment: 'Enterprise', country: 'India', amount: 44 }, +]; diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/example.spec.ts new file mode 100644 index 00000000000..2c0eed06460 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/example.spec.ts @@ -0,0 +1,8 @@ +import { ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + await ensureGridReady(page); + await waitForGridContent(page); + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/index.html b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/index.html new file mode 100644 index 00000000000..6c46dc75f2e --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/index.html @@ -0,0 +1 @@ +
diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/main.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/main.ts new file mode 100644 index 00000000000..165ad59e96e --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-editable-totals-custom/main.ts @@ -0,0 +1,120 @@ +import type { GridApi, GridOptions, GroupRowValueSetterFunc, ValueParserParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + ModuleRegistry, + NumberFilterModule, + TextEditorModule, + ValidationModule, + createGrid, +} from 'ag-grid-community'; +import { RowGroupingModule, SetFilterModule } from 'ag-grid-enterprise'; + +import { getData } from './data'; + +let gridApi: GridApi; + +interface SalesRecord { + id: string; + region: string; + segment: string; + country: string; + amount: number; +} + +ModuleRegistry.registerModules([ + RowGroupingModule, + ClientSideRowModelModule, + NumberFilterModule, + SetFilterModule, + TextEditorModule, + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), +]); + +// Parse input to integer +const amountValueParser = (params: ValueParserParams): number | null => { + const numericValue = Number(params.newValue); + return Number.isFinite(numericValue) ? Math.round(numericValue) : params.oldValue ?? null; +}; + +/** + * Distributes a new group total equally among children. + * + * `aggregatedChildren` contains the immediate children used for aggregation: + * - For leaf groups: the data rows + * - For non-leaf groups: the child groups + * + * Calling `setDataValue` on a child group triggers `groupRowValueSetter` again, + * enabling recursive cascade through the entire group hierarchy. + */ +const cascadeGroupTotal: GroupRowValueSetterFunc = ({ + column, + newValue, + eventSource, + aggregatedChildren, +}) => { + const total = Number(newValue); + if (!Number.isFinite(total) || !aggregatedChildren.length) { + return false; + } + + // Distribute equally among children + // https://en.wikipedia.org/wiki/Largest_remainder_method + const count = aggregatedChildren.length; + const base = Math.floor(total / count); + let remainder = Math.round(total) - base * count; + + // Apply the distributed values + let changed = false; + for (const child of aggregatedChildren) { + let value = base; + if (remainder > 0) { + value++; + remainder--; + } + if (child.setDataValue(column, value, eventSource)) { + changed = true; + } + } + return changed; +}; + +const gridOptions: GridOptions = { + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'segment', rowGroup: true, hide: true, filter: 'agSetColumnFilter' }, + { field: 'country', filter: 'agSetColumnFilter' }, + { + headerName: 'Amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + filter: 'agNumberColumnFilter', + valueParser: amountValueParser, + groupRowValueSetter: cascadeGroupTotal, + }, + ], + autoGroupColumnDef: { + minWidth: 260, + cellRendererParams: { + suppressCount: true, + }, + }, + defaultColDef: { + flex: 1, + sortable: true, + filter: true, + resizable: true, + }, + rowData: getData(), + groupAggFiltering: true, + groupDefaultExpanded: -1, + animateRows: true, + getRowId: ({ data }) => data.id, +}; + +// setup the grid after the page has finished loading +document.addEventListener('DOMContentLoaded', () => { + const gridDiv = document.querySelector('#myGrid')!; + gridApi = createGrid(gridDiv, gridOptions); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/data.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/data.ts new file mode 100644 index 00000000000..6ec603412d7 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/data.ts @@ -0,0 +1,56 @@ +export const getData = (): SalesRecord[] => [ + // Europe - Electronics + { id: 'eu-fr-elec', region: 'Europe', country: 'France', product: 'Electronics', amount: 120 }, + { id: 'eu-de-elec', region: 'Europe', country: 'Germany', product: 'Electronics', amount: 150 }, + { id: 'eu-es-elec', region: 'Europe', country: 'Spain', product: 'Electronics', amount: 80 }, + { id: 'eu-uk-elec', region: 'Europe', country: 'UK', product: 'Electronics', amount: 140 }, + { id: 'eu-it-elec', region: 'Europe', country: 'Italy', product: 'Electronics', amount: 95 }, + // Europe - Clothing + { id: 'eu-fr-cloth', region: 'Europe', country: 'France', product: 'Clothing', amount: 60 }, + { id: 'eu-de-cloth', region: 'Europe', country: 'Germany', product: 'Clothing', amount: 90 }, + { id: 'eu-es-cloth', region: 'Europe', country: 'Spain', product: 'Clothing', amount: 50 }, + { id: 'eu-uk-cloth', region: 'Europe', country: 'UK', product: 'Clothing', amount: 75 }, + { id: 'eu-it-cloth', region: 'Europe', country: 'Italy', product: 'Clothing', amount: 85 }, + // Europe - Food + { id: 'eu-fr-food', region: 'Europe', country: 'France', product: 'Food', amount: 40 }, + { id: 'eu-de-food', region: 'Europe', country: 'Germany', product: 'Food', amount: 55 }, + { id: 'eu-es-food', region: 'Europe', country: 'Spain', product: 'Food', amount: 35 }, + { id: 'eu-uk-food', region: 'Europe', country: 'UK', product: 'Food', amount: 48 }, + { id: 'eu-it-food', region: 'Europe', country: 'Italy', product: 'Food', amount: 52 }, + // Americas - Electronics + { id: 'am-us-elec', region: 'Americas', country: 'USA', product: 'Electronics', amount: 200 }, + { id: 'am-ca-elec', region: 'Americas', country: 'Canada', product: 'Electronics', amount: 100 }, + { id: 'am-br-elec', region: 'Americas', country: 'Brazil', product: 'Electronics', amount: 70 }, + { id: 'am-mx-elec', region: 'Americas', country: 'Mexico', product: 'Electronics', amount: 65 }, + { id: 'am-ar-elec', region: 'Americas', country: 'Argentina', product: 'Electronics', amount: 45 }, + // Americas - Clothing + { id: 'am-us-cloth', region: 'Americas', country: 'USA', product: 'Clothing', amount: 80 }, + { id: 'am-ca-cloth', region: 'Americas', country: 'Canada', product: 'Clothing', amount: 45 }, + { id: 'am-br-cloth', region: 'Americas', country: 'Brazil', product: 'Clothing', amount: 30 }, + { id: 'am-mx-cloth', region: 'Americas', country: 'Mexico', product: 'Clothing', amount: 35 }, + { id: 'am-ar-cloth', region: 'Americas', country: 'Argentina', product: 'Clothing', amount: 28 }, + // Americas - Food + { id: 'am-us-food', region: 'Americas', country: 'USA', product: 'Food', amount: 60 }, + { id: 'am-ca-food', region: 'Americas', country: 'Canada', product: 'Food', amount: 35 }, + { id: 'am-br-food', region: 'Americas', country: 'Brazil', product: 'Food', amount: 25 }, + { id: 'am-mx-food', region: 'Americas', country: 'Mexico', product: 'Food', amount: 32 }, + { id: 'am-ar-food', region: 'Americas', country: 'Argentina', product: 'Food', amount: 22 }, + // Asia Pacific - Electronics + { id: 'ap-jp-elec', region: 'Asia Pacific', country: 'Japan', product: 'Electronics', amount: 180 }, + { id: 'ap-cn-elec', region: 'Asia Pacific', country: 'China', product: 'Electronics', amount: 220 }, + { id: 'ap-kr-elec', region: 'Asia Pacific', country: 'South Korea', product: 'Electronics', amount: 160 }, + { id: 'ap-au-elec', region: 'Asia Pacific', country: 'Australia', product: 'Electronics', amount: 85 }, + { id: 'ap-in-elec', region: 'Asia Pacific', country: 'India', product: 'Electronics', amount: 110 }, + // Asia Pacific - Clothing + { id: 'ap-jp-cloth', region: 'Asia Pacific', country: 'Japan', product: 'Clothing', amount: 70 }, + { id: 'ap-cn-cloth', region: 'Asia Pacific', country: 'China', product: 'Clothing', amount: 120 }, + { id: 'ap-kr-cloth', region: 'Asia Pacific', country: 'South Korea', product: 'Clothing', amount: 55 }, + { id: 'ap-au-cloth', region: 'Asia Pacific', country: 'Australia', product: 'Clothing', amount: 40 }, + { id: 'ap-in-cloth', region: 'Asia Pacific', country: 'India', product: 'Clothing', amount: 95 }, + // Asia Pacific - Food + { id: 'ap-jp-food', region: 'Asia Pacific', country: 'Japan', product: 'Food', amount: 45 }, + { id: 'ap-cn-food', region: 'Asia Pacific', country: 'China', product: 'Food', amount: 75 }, + { id: 'ap-kr-food', region: 'Asia Pacific', country: 'South Korea', product: 'Food', amount: 38 }, + { id: 'ap-au-food', region: 'Asia Pacific', country: 'Australia', product: 'Food', amount: 30 }, + { id: 'ap-in-food', region: 'Asia Pacific', country: 'India', product: 'Food', amount: 55 }, +]; diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/example.spec.ts new file mode 100644 index 00000000000..2c0eed06460 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/example.spec.ts @@ -0,0 +1,8 @@ +import { ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + await ensureGridReady(page); + await waitForGridContent(page); + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/index.html b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/index.html new file mode 100644 index 00000000000..6c46dc75f2e --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/index.html @@ -0,0 +1 @@ +
diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/main.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/main.ts new file mode 100644 index 00000000000..1d1ce2593c9 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/pivot-editable-totals-custom/main.ts @@ -0,0 +1,122 @@ +import type { GridApi, GridOptions, GroupRowValueSetterFunc, ValueParserParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + ModuleRegistry, + NumberFilterModule, + TextEditorModule, + ValidationModule, + createGrid, +} from 'ag-grid-community'; +import { ColumnsToolPanelModule, PivotModule, RowGroupingModule, SideBarModule } from 'ag-grid-enterprise'; + +import { getData } from './data'; + +interface SalesRecord { + id: string; + region: string; + country: string; + product: string; + amount: number; +} + +let gridApi: GridApi; + +ModuleRegistry.registerModules([ + RowGroupingModule, + ClientSideRowModelModule, + NumberFilterModule, + TextEditorModule, + PivotModule, + SideBarModule, + ColumnsToolPanelModule, + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), +]); + +// Parse input to integer +const amountValueParser = (params: ValueParserParams): number | null => { + const numericValue = Number(params.newValue); + return Number.isFinite(numericValue) ? Math.round(numericValue) : params.oldValue ?? null; +}; + +/** + * Distributes a new pivot total equally among children. + * + * In pivot mode, `aggregatedChildren` contains only rows matching the pivot keys. + * For example, editing the "Electronics 2024" cell returns only rows where + * product="Electronics" AND year=2024. + * + * `setDataValue` on leaf rows with pivot columns auto-resolves to the underlying + * value column. On group rows, it triggers `groupRowValueSetter` for recursive cascade. + */ +const cascadeGroupTotal: GroupRowValueSetterFunc = ({ + column, + newValue, + eventSource, + aggregatedChildren, +}) => { + const total = Number(newValue); + if (!Number.isFinite(total) || !aggregatedChildren.length) { + return false; + } + + // Distribute equally among children + // https://en.wikipedia.org/wiki/Largest_remainder_method + const count = aggregatedChildren.length; + const base = Math.floor(total / count); + let remainder = Math.round(total) - base * count; + + // Apply the distributed values + let changed = false; + for (const child of aggregatedChildren) { + let value = base; + if (remainder > 0) { + value++; + remainder--; + } + if (child.setDataValue(column, value, eventSource)) { + changed = true; + } + } + return changed; +}; + +const gridOptions: GridOptions = { + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { field: 'product', pivot: true }, + { + headerName: 'Amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + valueParser: amountValueParser, + groupRowValueSetter: cascadeGroupTotal, + }, + ], + autoGroupColumnDef: { + minWidth: 200, + cellRendererParams: { + suppressCount: true, + }, + }, + defaultColDef: { + flex: 1, + minWidth: 120, + sortable: true, + filter: true, + resizable: true, + }, + pivotMode: true, + sideBar: 'columns', + rowData: getData(), + groupDefaultExpanded: -1, + getRowId: ({ data }) => data.id, +}; + +// setup the grid after the page has finished loading +document.addEventListener('DOMContentLoaded', () => { + const gridDiv = document.querySelector('#myGrid')!; + gridApi = createGrid(gridDiv, gridOptions); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/index.mdoc b/documentation/ag-grid-docs/src/content/docs/grouping-edit/index.mdoc index 3db4466d8cb..001d1ff08ca 100644 --- a/documentation/ag-grid-docs/src/content/docs/grouping-edit/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/index.mdoc @@ -4,7 +4,7 @@ enterprise: true --- The grid supports editing grouped data when using the [Client-Side Row Model](./row-models/#client-side). -This page explains how to keep the grouping hierarchy synchronised with edits. +This page explains how to keep the grouping hierarchy synchronised with edits and how to allow group rows themselves to be editable. ## Refreshing Groups After Editing @@ -24,3 +24,82 @@ stable IDs while rebuilding the grouping hierarchy. See also [Read Only Edit](./value-setters/#read-only-edit) for configuring immutable grouped data or connecting the grid with a store. + +## Editing Group Row Cells + +Set `groupRowEditable` on any column that should accept edits on group nodes. +Provide either a boolean or a callback; callbacks only run for nodes where `rowNode.group === true`, while leaf rows continue to honour `editable`. + +`groupRowValueSetter` mirrors the regular `valueSetter`, but it only fires for group rows. It runs +whenever a group row cell changes through the UI, `rowNode.setDataValue`, or another API call, +making it the right place to cascade edits to child nodes. + +Return `true` from the callback to inform the grid that the value changed and refresh is needed. + +Most grouped rows (and filler nodes in tree data) do not own `rowNode.data`, so their column +`valueSetter` never runs even if `groupRowEditable` is enabled. Provide a `groupRowValueSetter` +whenever the edit needs to persist or update aggregates; only group rows that own real data objects +run the normal value pipeline. + +When a column defines both `groupRowEditable` and `editable`, AG Grid only evaluates the property +that matches the current node type, enabling separate rules for group rows and leaves. + +## Custom Distribution of Edits + +In this example, `groupRowValueSetter` is used to distribute edits on group rows to their descendant rows. +The example uses a simple strategy of dividing the value equally among all visible children and sub groups. + +{% gridExampleRunner title="Custom Editable Group Totals" name="group-editable-totals-custom" exampleHeight=620 /%} + +### The aggregatedChildren Parameter + +The `groupRowValueSetter` callback receives an `aggregatedChildren` array containing the immediate children +that contribute to the [Aggregation](./aggregation/) for the edited column. This array makes it easy to +cascade edited values down to descendant rows without manually traversing the row hierarchy. + +The children returned by `aggregatedChildren` depend on the column and grid configuration: + +- **Regular value columns**: Returns direct children used for aggregation. This respects the + `suppressAggFilteredOnly` grid option and `groupAggFiltering` — when these cause aggregation to include + all children (not just filtered ones), `aggregatedChildren` returns all children accordingly. +- **Pivot columns on leaf groups**: Returns only the children matching the column's pivot keys, since + pivot aggregation groups rows by pivot key values. +- **Non-group rows**: Always returns an empty array — only group rows have aggregated children. + +Alternatively, use `rowNode.getAggregatedChildren(colKey)` anywhere in your code to retrieve the same +children programmatically. See [Retrieving Aggregated Children](./aggregation/#retrieving-aggregated-children) +for more details. + +### Cascading Edits + +Key points for implementing cascading edits: + +- Call `rowNode.setDataValue` on each child to push updated figures down. This triggers normal + aggregation to refresh parent totals. +- When a child is also a group that defines its own `groupRowValueSetter`, the cascade recurses further. +- Parent aggregates refresh automatically because child `data` changes re-run the column `aggFunc`, + so typing a new total into "Europe" instantly rebalances the children to reach that value. + +{% note %} +The `aggregatedChildren` parameter and `rowNode.getAggregatedChildren()` method are only supported +with the Client-Side Row Model. For other row models, `aggregatedChildren` is an empty array. +{% /note %} + +## Editing Pivot Columns + +When using [Pivoting](./pivoting/), the `groupRowValueSetter` also works with pivot result columns. +The key difference is that `aggregatedChildren` returns only the children matching the column's pivot keys, +not all children of the group. + +{% gridExampleRunner title="Custom Editable Pivot Totals" name="pivot-editable-totals-custom" exampleHeight=500 /%} + +In this example: + +- The grid is pivoted by `product` (Electronics, Clothing, Food), grouped by `region` and `country`. +- Double-click on any pivot column cell to edit the aggregated value. +- When editing a pivot cell (e.g., "Electronics" column on "Europe" row), the `aggregatedChildren` array + contains only the European rows selling Electronics — not all European rows. +- The edit is distributed equally among just those matching children, leaving other product categories unchanged. + +This behaviour ensures that edits on pivot columns affect only the data that contributes to that specific +pivot value, making cascading edits work correctly with pivoted data. diff --git a/documentation/ag-grid-docs/src/content/docs/pivoting/index.mdoc b/documentation/ag-grid-docs/src/content/docs/pivoting/index.mdoc index 7df680e9f38..a6e10d7924e 100644 --- a/documentation/ag-grid-docs/src/content/docs/pivoting/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/pivoting/index.mdoc @@ -112,3 +112,7 @@ The pivot state can be saved and restored as part of [Grid State](./grid-state/) Pivoting can be controlled using the following grid API methods: {% apiDocumentation source="grid-api/api.json" section="rowPivoting" /%} + +## See Also + +- [Editing Groups](./grouping-edit/) for editing pivot aggregated values with cascading updates to children diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-async-values/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-async-values/example.spec.ts index ed79767b830..b6cad2cb745 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-async-values/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-async-values/example.spec.ts @@ -1,11 +1,57 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; + +const languages = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework('should load async values and display all options in dropdown', async ({ agIdFor, page }) => { + // Verify the first cell shows a valid language value + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(languages).toContain(cellText); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // Wait for the popup to appear + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Wait for async values to load (1s server delay) + const options = popup.locator('.ag-rich-select-row'); + await expect(options).toHaveCount(5, { timeout: 5000 }); + + // Verify all language options are present + for (const lang of languages) { + await expect(popup.locator('.ag-rich-select-row', { hasText: lang }).first()).toBeVisible(); + } + + await page.keyboard.press('Escape'); }); + + test.eachFramework( + 'should update cell value when selecting an option from the async dropdown', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + const originalValue = await cell.textContent(); + + // Pick a different language + const newLanguage = languages.find((lang) => lang !== originalValue)!; + + // Open the editor + await cell.dblclick(); + + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Wait for async options to load + await expect(popup.locator('.ag-rich-select-row')).toHaveCount(5, { timeout: 5000 }); + + // Select the new language + const option = popup.locator('.ag-rich-select-row', { hasText: newLanguage }).first(); + await option.click(); + + // Verify the cell value updated + await expect(cell).toHaveText(newLanguage); + } + ); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-full-async-values/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-full-async-values/example.spec.ts index ed79767b830..7ca3db2f859 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-full-async-values/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-full-async-values/example.spec.ts @@ -1,11 +1,70 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; + +const languages = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework('should open editor with a visible typing input', async ({ agIdFor, page }) => { + // Verify the first cell shows a valid language value + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(languages).toContain(cellText); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // With filterListAsync + allowTyping, the text input is visible immediately + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + await page.keyboard.press('Escape'); }); + + test.eachFramework('should show filtered async results after typing a search term', async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Open editor + await cell.dblclick(); + + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + // Fill with 'Sp' — only 'Spanish' matches the server-side filter + await editorInput.fill('Sp'); + + // Popup should appear once the async response resolves (debounce 300ms + server 1000ms) + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible({ timeout: 5000 }); + + // Only Spanish matches 'Sp' + await expect(popup.locator('.ag-rich-select-row')).toHaveCount(1, { timeout: 5000 }); + await expect(popup.locator('.ag-rich-select-row', { hasText: 'Spanish' }).first()).toBeVisible(); + + await page.keyboard.press('Escape'); + }); + + test.eachFramework( + 'should update cell value when selecting from the async-filtered list', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Open editor + await cell.dblclick(); + + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + // Filter to 'English' specifically (exact match, returns only English) + await editorInput.fill('English'); + + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible({ timeout: 5000 }); + + // Click the English option + const option = popup.locator('.ag-rich-select-row', { hasText: 'English' }).first(); + await option.click(); + + // Verify the cell updated + await expect(cell).toHaveText('English'); + } + ); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-filtering/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-filtering/example.spec.ts index ed79767b830..2b5534a2edc 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-filtering/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-filtering/example.spec.ts @@ -1,11 +1,71 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework('should open editor with a visible typing input', async ({ agIdFor, page }) => { + // Verify the first cell shows a language in the expected format + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(cellText).toMatch(/^Language \d+$/); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // With filterListAsync + allowTyping, the text input is visible immediately + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + await page.keyboard.press('Escape'); + }); + + test.eachFramework('should show filtered paged results after typing a search term', async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Open editor + await cell.dblclick(); + + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + // Type '5000' — matches 'Language 5000' and 'Language 15000' in the 20,000-item dataset + await editorInput.fill('5000'); + + // Popup appears once the server responds (debounce 300ms + server 300ms) + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible({ timeout: 5000 }); + + // 'Language 5000' must be among the filtered results + await expect(popup.locator('.ag-rich-select-row', { hasText: 'Language 5000' }).first()).toBeVisible({ + timeout: 5000, + }); + + await page.keyboard.press('Escape'); }); + + test.eachFramework( + 'should update cell value when selecting from filtered paged results', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Open editor + await cell.dblclick(); + + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + // Fill with exact text 'Language 5000' — this is the only 20,000-item match + // ('Language 15000' does not contain 'Language 5000' as a substring) + await editorInput.fill('Language 5000'); + + // Wait for filtered results + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible({ timeout: 5000 }); + + const option = popup.locator('.ag-rich-select-row', { hasText: 'Language 5000' }).first(); + await expect(option).toBeVisible({ timeout: 5000 }); + await option.click(); + + // Verify the cell value updated + await expect(cell).toHaveText('Language 5000'); + } + ); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-values/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-values/example.spec.ts index ed79767b830..615930de57e 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-values/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-async/_examples/rich-select-paged-async-values/example.spec.ts @@ -1,11 +1,94 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER - }); + test.eachFramework( + 'should load first page of values and display languages in expected format', + async ({ agIdFor, page }) => { + // Verify the first cell shows a value in the expected format + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(cellText).toMatch(/^Language \d+$/); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // Wait for the popup to appear + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Wait for the first page to load (300ms server delay) + const firstOption = popup.locator('.ag-rich-select-row').first(); + await expect(firstOption).toBeVisible({ timeout: 5000 }); + + // Verify options follow the expected pattern + const firstOptionText = await firstOption.textContent(); + expect(firstOptionText).toMatch(/^Language \d+$/); + + await page.keyboard.press('Escape'); + } + ); + + test.eachFramework( + 'should load the next page of values when scrolling to the bottom of the dropdown', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + await cell.dblclick(); + + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Scroll to the top so we start from a known position and wait for top items to load + await popup.evaluate((el) => { + el.scrollTop = 0; + }); + await expect(popup.locator('.ag-rich-select-row').first()).toBeVisible({ timeout: 5000 }); + + // Record the language number of the last visible row at the top of the list + const lastVisibleRowBefore = popup.locator('.ag-rich-select-row').last(); + const textBefore = (await lastVisibleRowBefore.textContent()) ?? ''; + const numberBefore = parseInt(/Language (\d+)/.exec(textBefore)?.[1] ?? '0'); + expect(numberBefore).toBeGreaterThan(0); + + // Scroll to the bottom of the virtual list (represents all 20,000 items) to trigger + // next-page loading — the load threshold is 8 rows from the end of loaded data + await popup.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + + // After scrolling, new pages are fetched from the server (300ms delay). + // The last visible row should now show a higher-numbered language than before. + await expect(async () => { + const lastVisibleRowAfter = popup.locator('.ag-rich-select-row').last(); + const textAfter = (await lastVisibleRowAfter.textContent()) ?? ''; + const numberAfter = parseInt(/Language (\d+)/.exec(textAfter)?.[1] ?? '0'); + expect(numberAfter).toBeGreaterThan(numberBefore); + }).toPass({ timeout: 5000 }); + + await page.keyboard.press('Escape'); + } + ); + + test.eachFramework( + 'should update cell value when selecting an option from the paged dropdown', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Open editor + await cell.dblclick(); + + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Wait for first page to load + const firstOption = popup.locator('.ag-rich-select-row').first(); + await expect(firstOption).toBeVisible({ timeout: 5000 }); + + // Read the text of the first visible option (initial page position depends on current cell value) + const optionText = (await firstOption.textContent())!.trim(); + + // Click it and verify the cell updates to that value + await firstOption.click(); + await expect(cell).toContainText(optionText); + } + ); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-allow-typing/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-allow-typing/example.spec.ts index ed79767b830..6ca062bf533 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-allow-typing/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-allow-typing/example.spec.ts @@ -1,11 +1,71 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework( + 'should display both column headers and open editor with typing input for Match column', + async ({ agIdFor, page }) => { + // Verify both column headers are visible + const matchHeader = page.locator('.ag-header-cell', { hasText: 'Allow Typing (Match)' }); + await expect(matchHeader.first()).toBeVisible(); + + const matchAnyHeader = page.locator('.ag-header-cell', { hasText: 'Allow Typing (MatchAny)' }); + await expect(matchAnyHeader.first()).toBeVisible(); + + // Double-click the first cell in the Match column to open the editor + const cell = agIdFor.cell('0', 'color').first(); + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify the text input is visible inside the editor (proves allowTyping: true) + const editorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await expect(editorInput).toBeVisible(); + + // Close the editor + await page.keyboard.press('Escape'); + } + ); + + test.eachFramework('should filter list differently based on search type when typing', async ({ agIdFor, page }) => { + // --- Test Match column (prefix search) --- + + const matchCell = agIdFor.cell('0', 'color').first(); + await matchCell.dblclick(); + + const matchPopup = page.locator('.ag-rich-select-list').first(); + await expect(matchPopup).toBeVisible(); + + // Type 'Blue' to filter the list by prefix + const matchEditorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await matchEditorInput.fill('Blue'); + + // 'Blue' should appear in the filtered list (starts with 'Blue') + await expect(matchPopup.locator('.ag-rich-select-row', { hasText: 'Blue' }).first()).toBeVisible(); + + // 'AliceBlue' should NOT appear because it does not start with 'Blue' (prefix search) + await expect(matchPopup.locator('.ag-rich-select-row', { hasText: 'AliceBlue' })).toHaveCount(0); + + // Close the editor + await page.keyboard.press('Escape'); + + // --- Test MatchAny column (substring search) --- + + const matchAnyCell = agIdFor.cell('0', 'color_1').first(); + await matchAnyCell.dblclick(); + + const matchAnyPopup = page.locator('.ag-rich-select-list').first(); + await expect(matchAnyPopup).toBeVisible(); + + // Type 'Blue' to filter the list by substring + const matchAnyEditorInput = page.locator('.ag-rich-select-field-input .ag-input-field-input').first(); + await matchAnyEditorInput.fill('Blue'); + + // 'AliceBlue' should appear because it contains 'Blue' (substring search) + await expect(matchAnyPopup.locator('.ag-rich-select-row', { hasText: 'AliceBlue' }).first()).toBeVisible(); + + // Close the editor + await page.keyboard.press('Escape'); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-cell-renderer/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-cell-renderer/example.spec.ts index ed79767b830..efe20ed6d67 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-cell-renderer/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-cell-renderer/example.spec.ts @@ -1,11 +1,49 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework( + 'should display header, valid cell content, and open editor with options', + async ({ agIdFor, page }) => { + // Verify the column header is visible with the expected text + const header = agIdFor.headerCell('color'); + await expect(header).toContainText('Rich Select Editor'); + + // Verify the first cell displays a non-empty colour name + const cell = agIdFor.cell('0', 'color').first(); + const cellText = await cell.textContent(); + expect(cellText?.trim().length).toBeGreaterThan(0); + + // Double-click the cell to open the rich select editor + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify the popup contains colour options + const options = popup.locator('.ag-rich-select-row'); + await expect(options.first()).toBeVisible(); + } + ); + + test.eachFramework('should update cell value when selecting a different option', async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'color').first(); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // Wait for the popup to appear + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Click the first visible row in the popup (the list opens scrolled to the current + // selected value, so we click whatever row is at the top of the viewport) + const firstRow = popup.locator('.ag-rich-select-row').first(); + await expect(firstRow).toBeVisible(); + const optionText = (await firstRow.innerText()).trim(); + await firstRow.click(); + + // Verify the cell value has been updated to the selected colour + await expect(cell).toContainText(optionText); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-complex-objects/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-complex-objects/example.spec.ts index ed79767b830..03b86bacbbd 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-complex-objects/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-complex-objects/example.spec.ts @@ -1,11 +1,83 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework( + 'should display correct initial cell values for both string and complex object columns', + async ({ agIdFor }) => { + // Verify the column headers are visible with the expected text + const stringHeader = agIdFor.headerCell('color'); + await expect(stringHeader).toContainText('Color (Column as String Type)'); + + const objectHeader = agIdFor.headerCell('detailedColor'); + await expect(objectHeader).toContainText('Color (Column as Complex Object)'); + + // Verify row 0 string column shows the plain name + const row0Color = agIdFor.cell('0', 'color'); + await expect(row0Color).toContainText('Pink'); + + // Verify row 0 complex object column shows the formatted value + const row0DetailedColor = agIdFor.cell('0', 'detailedColor'); + await expect(row0DetailedColor).toContainText('Pink (#FFC0CB)'); + + // Verify row 2 string column shows the plain name + const row2Color = agIdFor.cell('2', 'color'); + await expect(row2Color).toContainText('Blue'); + + // Verify row 2 complex object column shows the formatted value + const row2DetailedColor = agIdFor.cell('2', 'detailedColor'); + await expect(row2DetailedColor).toContainText('Blue (#0000FF)'); + } + ); + + test.eachFramework('should update cell value when selecting a different option', async ({ agIdFor, page }) => { + // --- Edit the string column (color) --- + + const colorCell = agIdFor.cell('0', 'color'); + + // Double-click to open the rich select editor + await colorCell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify all 4 colour options are displayed + const options = popup.locator('.ag-rich-select-row'); + await expect(options).toHaveCount(4); + + // Verify the option labels show the formatted name values + await expect(options.nth(0)).toContainText('Pink'); + await expect(options.nth(1)).toContainText('Purple'); + await expect(options.nth(2)).toContainText('Blue'); + await expect(options.nth(3)).toContainText('Green'); + + // Click the "Green" option + const greenOption = popup.locator('.ag-rich-select-row', { hasText: 'Green' }).first(); + await greenOption.click(); + + // Verify the cell value has been updated via parseValue (extracts just the name string) + await expect(colorCell).toContainText('Green'); + + // --- Edit the complex object column (detailedColor) --- + + const detailedColorCell = agIdFor.cell('1', 'detailedColor'); + + // Double-click to open the rich select editor + await detailedColorCell.dblclick(); + + // Verify the rich select popup list appears + const objectPopup = page.locator('.ag-rich-select-list').first(); + await expect(objectPopup).toBeVisible(); + + // Verify all 4 colour options are displayed + const objectOptions = objectPopup.locator('.ag-rich-select-row'); + await expect(objectOptions).toHaveCount(4); + + // Click the "Blue" option + const blueOption = objectPopup.locator('.ag-rich-select-row', { hasText: 'Blue' }).first(); + await blueOption.click(); + + // Verify the cell value has been updated with the valueFormatter applied to the full complex object + await expect(detailedColorCell).toContainText('Blue (#0000FF)'); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-format-values/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-format-values/example.spec.ts index ed79767b830..bc5e20fc5c1 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-format-values/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-format-values/example.spec.ts @@ -1,11 +1,56 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; + +const languages = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER - }); + test.eachFramework( + 'should display header, original-case cell values, and uppercase options in editor', + async ({ agIdFor, page }) => { + // Verify the column header is visible with the expected text + const header = agIdFor.headerCell('language'); + await expect(header).toContainText('Rich Select Editor'); + + // Verify the first cell displays a valid language value in original case (not uppercased) + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(languages).toContain(cellText); + + // Double-click the cell to open the rich select editor + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify all 5 language options are displayed + const options = popup.locator('.ag-rich-select-row'); + await expect(options).toHaveCount(5); + + // Verify the formatValue callback displays items in uppercase + await expect(popup.locator('.ag-rich-select-row', { hasText: 'ENGLISH' }).first()).toBeVisible(); + await expect(popup.locator('.ag-rich-select-row', { hasText: 'SPANISH' }).first()).toBeVisible(); + await expect(popup.locator('.ag-rich-select-row', { hasText: '(OTHER)' }).first()).toBeVisible(); + } + ); + + test.eachFramework( + 'should store original-case value when selecting a formatted uppercase option', + async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Double-click to open the rich select editor + await cell.dblclick(); + + // Wait for the popup to appear + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Click the "FRENCH" option (displayed in uppercase by formatValue) + const option = popup.locator('.ag-rich-select-row', { hasText: 'FRENCH' }).first(); + await option.click(); + + // Verify the cell stores and displays the original-case value, not the formatted uppercase + await expect(cell).toContainText('French'); + } + ); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-multi-select/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-multi-select/example.spec.ts index ed79767b830..5ff7cd45ea5 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-multi-select/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-multi-select/example.spec.ts @@ -1,11 +1,65 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework( + 'should display header and keep multi-select editor open after clicking options', + async ({ agIdFor, page }) => { + // Verify the column header contains the expected text + const header = agIdFor.headerCell('colors'); + await expect(header).toContainText('Colours'); + + // Double-click the first row cell to open the rich select editor + const cell = agIdFor.cell('0', 'colors'); + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Click the first visible option in the popup (the list opens scrolled to the + // current selection, so we interact with whatever rows are in the viewport) + const firstOption = popup.locator('.ag-rich-select-row').first(); + await expect(firstOption).toBeVisible(); + await firstOption.click(); + + // Multi-select: popup should remain open after clicking an option + await expect(popup).toBeVisible(); + + // Click the second visible option + const secondOption = popup.locator('.ag-rich-select-row').nth(1); + await expect(secondOption).toBeVisible(); + await secondOption.click(); + + // Multi-select: popup should still remain open after clicking another option + await expect(popup).toBeVisible(); + + // Press Escape to cancel editing without confirming changes + await page.keyboard.press('Escape'); + + // Verify the popup is no longer visible after pressing Escape + await expect(popup).not.toBeVisible(); + } + ); + + test.eachFramework('should show selected state for options in multi-select editor', async ({ agIdFor, page }) => { + // Double-click the first row cell to open the rich select editor + const cell = agIdFor.cell('0', 'colors'); + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify that at least one option has the selected class, reflecting + // the pre-selected colours from the initial random row data (1-4 colours per row) + const selectedRows = popup.locator('.ag-rich-select-row-selected'); + const selectedCount = await selectedRows.count(); + expect(selectedCount).toBeGreaterThanOrEqual(1); + + // Press Escape to close the editor + await page.keyboard.press('Escape'); + + // Verify the popup is no longer visible + await expect(popup).not.toBeVisible(); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-search-values/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-search-values/example.spec.ts index ed79767b830..fe7735973e6 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-search-values/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select-customisation/_examples/rich-select-search-values/example.spec.ts @@ -1,11 +1,37 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework('should display all three search mode column headers', async ({ page }) => { + // Verify all three column headers are visible with the expected text + const fuzzyHeader = page.locator('.ag-header-cell', { hasText: 'Fuzzy Search' }); + await expect(fuzzyHeader).toBeVisible(); + + const matchHeader = page.locator('.ag-header-cell', { hasText: 'Match Search' }); + await expect(matchHeader).toBeVisible(); + + const matchAnyHeader = page.locator('.ag-header-cell', { hasText: 'Match Any Search' }); + await expect(matchAnyHeader).toBeVisible(); + }); + + test.eachFramework('should open the rich select editor popup with colour options', async ({ agIdFor, page }) => { + // Get the first cell in the Fuzzy Search column (col-id 'color') + const cell = agIdFor.cell('0', 'color').first(); + await expect(cell).toBeVisible(); + + // Double-click the cell to open the rich select editor + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify the popup contains colour options + await expect(popup.locator('.ag-rich-select-row').first()).toBeVisible(); + + // Close the editor by pressing Escape + await page.keyboard.press('Escape'); + + // Verify the popup is no longer visible + await expect(popup).not.toBeVisible(); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select/_examples/rich-select-editor/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select/_examples/rich-select-editor/example.spec.ts index ed79767b830..10941f6cd48 100644 --- a/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select/_examples/rich-select-editor/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/provided-cell-editors-rich-select/_examples/rich-select-editor/example.spec.ts @@ -1,11 +1,54 @@ -import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; +import { expect, test } from '@utils/grid/test-utils'; + +const languages = ['English', 'Spanish', 'French', 'Portuguese', '(other)']; test.agExample(import.meta, () => { - test.eachFramework('Example', async ({ page }) => { - // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS - await ensureGridReady(page); - await waitForGridContent(page); - await clickAllButtons(page); - // END PLACEHOLDER + test.eachFramework( + 'should display header, valid cell content, and open editor with all options', + async ({ agIdFor, page }) => { + // Verify the column header is visible with the expected text + const header = agIdFor.headerCell('language'); + await expect(header).toContainText('Rich Select Editor'); + + // Verify the first cell displays a valid language value + const cell = agIdFor.cell('0', 'language'); + const cellText = await cell.textContent(); + expect(languages).toContain(cellText); + + // Double-click the cell to open the rich select editor + await cell.dblclick(); + + // Verify the rich select popup list appears + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Verify all 5 language options are displayed + const options = popup.locator('.ag-rich-select-row'); + await expect(options).toHaveCount(5); + } + ); + + test.eachFramework('should update cell value when selecting a different option', async ({ agIdFor, page }) => { + const cell = agIdFor.cell('0', 'language'); + + // Read the current cell value + const originalValue = await cell.textContent(); + + // Pick a language that differs from the current value + const newLanguage = languages.find((lang) => lang !== originalValue)!; + + // Double-click to open the rich select editor + await cell.dblclick(); + + // Wait for the popup to appear + const popup = page.locator('.ag-rich-select-list').first(); + await expect(popup).toBeVisible(); + + // Click the option with the different language + const option = popup.locator('.ag-rich-select-row', { hasText: newLanguage }).first(); + await option.click(); + + // Verify the cell value has been updated to the newly selected language + await expect(cell).toHaveText(newLanguage); }); }); diff --git a/documentation/ag-grid-docs/src/content/docs/tree-data-nesting/index.mdoc b/documentation/ag-grid-docs/src/content/docs/tree-data-nesting/index.mdoc index a82324ade10..15e8956f4f5 100644 --- a/documentation/ag-grid-docs/src/content/docs/tree-data-nesting/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/tree-data-nesting/index.mdoc @@ -88,4 +88,5 @@ const gridOptions = { }; ``` -Refer to the [Aggregation](./aggregation/) page for more details. +Refer to the [Aggregation](./aggregation/) page for more details, and [Editing Groups](./grouping-edit/) +for editing aggregated values with cascading updates to children. diff --git a/documentation/ag-grid-docs/src/content/docs/tree-data-paths/index.mdoc b/documentation/ag-grid-docs/src/content/docs/tree-data-paths/index.mdoc index 60c65935ff3..71ad2d0e2f8 100644 --- a/documentation/ag-grid-docs/src/content/docs/tree-data-paths/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/tree-data-paths/index.mdoc @@ -112,4 +112,6 @@ const gridOptions = { }; ``` -Refer to the [Aggregation](./aggregation/) page for more details. +Refer to the [Aggregation](./aggregation/) page for more details, and [Editing Groups](./grouping-edit/) +for editing aggregated values with cascading updates to children. + diff --git a/documentation/ag-grid-docs/src/content/docs/tree-data-row-dragging/index.mdoc b/documentation/ag-grid-docs/src/content/docs/tree-data-row-dragging/index.mdoc index 2d24dfbc2ec..55ebacbc018 100644 --- a/documentation/ag-grid-docs/src/content/docs/tree-data-row-dragging/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/tree-data-row-dragging/index.mdoc @@ -177,3 +177,4 @@ This example also demonstrates how to provide custom drop indicators using the [ ## See Also - [Aggregation](./aggregation/) for aggregating values in tree data +- [Editing Groups](./grouping-edit/) for editing aggregated values with cascading updates to children diff --git a/documentation/ag-grid-docs/src/content/docs/tree-data-self-referential/index.mdoc b/documentation/ag-grid-docs/src/content/docs/tree-data-self-referential/index.mdoc index b2c931bd384..1e7db01a67d 100644 --- a/documentation/ag-grid-docs/src/content/docs/tree-data-self-referential/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/tree-data-self-referential/index.mdoc @@ -98,4 +98,6 @@ const gridOptions = { }; ``` -Refer to the [Aggregation](./aggregation/) page for more details. +Refer to the [Aggregation](./aggregation/) page for more details, and [Editing Groups](./grouping-edit/) +for editing aggregated values with cascading updates to children. + diff --git a/packages/ag-grid-community/knip.json b/packages/ag-grid-community/knip.json index 1f30a750b0b..2d57cb5eff9 100644 --- a/packages/ag-grid-community/knip.json +++ b/packages/ag-grid-community/knip.json @@ -2,7 +2,6 @@ "entry": ["./src/main.ts"], "project": ["./src/**"], "ignore": [ - "./src/entities/colDefInternal.ts", "./src/interfaces/iAgChartOptions.ts", "./src/theming/createTheme.ts", "./src/theming/private-theming-api.ts", diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index 5bbeef0df51..a53acef6669 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -2,7 +2,6 @@ import { KeyCode } from '../agStack/constants/keyCode'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { AgColumn } from '../entities/agColumn'; -import type { ColDefInternal } from '../entities/colDefInternal'; import { _getRowNode } from '../entities/positionUtils'; import type { RowNode } from '../entities/rowNode'; import type { AgEventType } from '../eventTypes'; @@ -840,8 +839,7 @@ export class EditService extends BeanStub implements NamedBean { const { gos, beans } = this; const rowNode = position.rowNode; - const colDef: ColDefInternal = position.column.getColDef(); - if (rowNode.group && colDef.groupRowEditable == null) { + if (rowNode.group && position.column.getColDef().groupRowEditable == null) { // This is a group - it could be a tree group or a grouping group... if (gos.get('treeData')) { // tree - allow editing of groups with data by default. diff --git a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts index 30485c21875..acfbe5585b1 100644 --- a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts +++ b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts @@ -2,7 +2,6 @@ import { KeyCode } from '../../agStack/constants/keyCode'; import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; import type { ColDef } from '../../entities/colDef'; -import type { ColDefInternal } from '../../entities/colDefInternal'; import type { GridOptionsService } from '../../gridOptionsService'; import type { EditPosition, EditSource } from '../../interfaces/iEditService'; @@ -74,7 +73,7 @@ function existingEditing(beans: BeanCollection, editPosition: Required): boolean { const column = editPosition.column as AgColumn; const rowNode = editPosition.rowNode; - const colDef = column.getColDef() as ColDefInternal; + const colDef = column.getColDef(); if (!rowNode) { return existingEditing(beans, editPosition); diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index 2a732a41d48..21a63db8336 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -5,7 +5,6 @@ import { _getCellEditorDetails } from '../../components/framework/userCompUtils' import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; import type { ColDef } from '../../entities/colDef'; -import type { ColDefInternal } from '../../entities/colDefInternal'; import type { CellEditingStoppedEvent } from '../../events'; import { _addGridCommonParams } from '../../gridOptionsUtils'; import type { @@ -616,7 +615,7 @@ function _columnDefsRequireValidation(columnDefs?: ColDef[]): boolean { return false; } for (let i = 0, len = columnDefs.length; i < len; ++i) { - const colDef: ColDefInternal = columnDefs[i]; + const colDef = columnDefs[i]; const params = colDef.cellEditorParams; if (!params || (!colDef.editable && !colDef.groupRowEditable)) { continue; diff --git a/packages/ag-grid-community/src/entities/colDef.ts b/packages/ag-grid-community/src/entities/colDef.ts index 4f525ed4954..3d1117519cc 100644 --- a/packages/ag-grid-community/src/entities/colDef.ts +++ b/packages/ag-grid-community/src/entities/colDef.ts @@ -393,9 +393,21 @@ export interface ColDef extends AbstractColDef; + /** + * Works like `editable`, but is evaluated only for group rows. When provided, group rows use this property instead of `editable`. + * Set to `true` if this column is editable, otherwise `false`. Can also be a function to have different rows editable. + */ + groupRowEditable?: boolean | GroupRowEditableCallback; + /** + * Runs after a group row value changes so custom code can push edits down to descendant rows. + * Fires for every `setDataValue` call when defined, regardless of `groupRowEditable`. + * Use this to mutate descendants directly; the grid always commits the group row value afterwards. + */ + groupRowValueSetter?: GroupRowValueSetterFunc; /** * Function or expression. Sets the value into your data for saving. Return `true` if the data changed. */ @@ -965,6 +977,39 @@ export interface EditableCallbackParams = ( params: EditableCallbackParams ) => boolean; +export interface GroupRowEditableCallbackParams + extends ColumnFunctionCallbackParams {} +export type GroupRowEditableCallback = ( + params: GroupRowEditableCallbackParams +) => boolean; +export interface GroupRowValueSetterParams + extends Omit< + ChangedValueParams, + 'node' | 'data' + > { + /** Group row that triggered the callback. */ + node: IRowNode; + /** Data associated with the group row. Undefined when the row does not own data. */ + data?: TData | null; + /** Source string provided to `rowNode.setDataValue`. */ + eventSource: string | undefined; + /** Whether the value actually changed. */ + valueChanged: boolean; + /** + * The immediate children that contribute to the aggregation. + * + * - For leaf groups (groups containing data rows): returns the data rows. + * With pivot columns, only rows matching the pivot keys are included. + * - For non-leaf groups (groups containing other groups): returns the child groups. + * Use `setDataValue` on child groups to cascade recursively. + * + * **Note:** Only supported with the Client-Side Row Model. + */ + aggregatedChildren: IRowNode[]; +} +export type GroupRowValueSetterFunc = ( + params: GroupRowValueSetterParams +) => void | boolean | undefined; export interface SuppressPasteCallbackParams extends ColumnFunctionCallbackParams {} export type SuppressPasteCallback = ( diff --git a/packages/ag-grid-community/src/entities/colDefInternal.ts b/packages/ag-grid-community/src/entities/colDefInternal.ts deleted file mode 100644 index e7391aa80d4..00000000000 --- a/packages/ag-grid-community/src/entities/colDefInternal.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Internal column definition properties for features that are not yet public. - * These types are used internally but not exported in the public API. - * @internal - */ -import type { GridApi } from '../api/gridApi'; -import type { Column } from '../interfaces/iColumn'; -import type { IRowNode } from '../interfaces/iRowNode'; -import type { ColDef, ColumnFunctionCallbackParams } from './colDef'; - -/** @internal */ -export interface GroupRowEditableCallbackParams - extends ColumnFunctionCallbackParams {} - -/** @internal */ -export type GroupRowEditableCallback = ( - params: GroupRowEditableCallbackParams -) => boolean; - -/** @internal */ -export interface GroupRowValueSetterParams { - /** The value before the change. */ - oldValue: TValue | null | undefined; - /** The value after the change. */ - newValue: TValue | null | undefined; - /** Group row that triggered the callback. */ - node: IRowNode; - /** Data associated with the group row. Undefined when the row does not own data. */ - data?: TData | null; - /** The column being edited. */ - column: Column; - /** The column definition being edited. */ - colDef: ColDef; - /** The grid api. */ - api: GridApi; - /** Application context as set on `gridOptions.context`. */ - context: TContext; - /** Source string provided to `rowNode.setDataValue`. */ - eventSource: string | undefined; - /** Whether the value actually changed. */ - valueChanged: boolean; - /** - * The immediate children that contribute to the aggregation. - * - * - For leaf groups (groups containing data rows): returns the data rows. - * With pivot columns, only rows matching the pivot keys are included. - * - For non-leaf groups (groups containing other groups): returns the child groups. - * Use `setDataValue` on child groups to cascade recursively. - * - * **Note:** Only supported with the Client-Side Row Model. - */ - aggregatedChildren: IRowNode[]; -} - -/** @internal */ -export type GroupRowValueSetterFunc = ( - params: GroupRowValueSetterParams -) => void | boolean | undefined; - -/** - * Internal extension to ColDef for features not yet public. - * Use this type internally when accessing internal properties. - * @internal - */ -export interface ColDefInternal extends ColDef { - /** - * Works like `editable`, but is evaluated only for group rows. When provided, group rows use this property instead of `editable`. - * Set to `true` if this column is editable, otherwise `false`. Can also be a function to have different rows editable. - * @internal - */ - groupRowEditable?: boolean | GroupRowEditableCallback; - /** - * Runs after a group row value changes so custom code can push edits down to descendant rows. - * Fires for every `setDataValue` call when defined, regardless of `groupRowEditable`. - * Use this to mutate descendants directly; the grid always commits the group row value afterwards. - * @internal - */ - groupRowValueSetter?: GroupRowValueSetterFunc; -} diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 7c40b1e33f2..f169f7e5e1a 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -711,6 +711,10 @@ export { ValueParserParams, ValueSetterFunc, ValueSetterParams, + GroupRowEditableCallback, + GroupRowEditableCallbackParams, + GroupRowValueSetterParams, + GroupRowValueSetterFunc, } from './entities/colDef'; export { BaseCellDataType, diff --git a/packages/ag-grid-community/src/validation/rules/colDefValidations.ts b/packages/ag-grid-community/src/validation/rules/colDefValidations.ts index 7fd9c877b96..9176daa8d4c 100644 --- a/packages/ag-grid-community/src/validation/rules/colDefValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/colDefValidations.ts @@ -1,7 +1,6 @@ import type { UserComponentName } from '../../context/context'; import { _isSortDefValid, _isSortDirectionValid } from '../../entities/agColumn'; import type { AbstractColDef, ColDef, ColGroupDef, ColumnMenuTab } from '../../entities/colDef'; -import type { ColDefInternal } from '../../entities/colDefInternal'; import { _errMsg, toStringWithNullUndefined } from '../logging'; import type { Deprecations, ModuleValidation, OptionsValidator, Validations } from '../validationTypes'; import { USER_COMP_MODULES } from './userCompValidations'; @@ -40,8 +39,7 @@ export const COLUMN_DEFINITION_MOD_VALIDATIONS: ModuleValidation { - const groupRowEditable = (rest as ColDefInternal).groupRowEditable; + cellEditor: ({ cellEditor, editable, groupRowEditable }: ColDef) => { const editingEnabled = !!editable || !!groupRowEditable; if (!editingEnabled) { return null; @@ -68,6 +66,12 @@ export const COLUMN_DEFINITION_MOD_VALIDATIONS: ModuleValidation { + if (groupRowEditable && !cellEditor) { + return 'TextEditor'; + } + return null; + }, enableCellChangeFlash: 'HighlightChanges', enablePivot: 'SharedPivot', enableRowGroup: 'SharedRowGrouping', @@ -256,6 +260,7 @@ const COLUMN_DEFINITION_VALIDATIONS: () => Validations = ( spanRows: { dependencies: { editable: { required: [false, undefined] }, + groupRowEditable: { required: [false, undefined] }, rowDrag: { required: [false, undefined] }, colSpan: { required: [undefined] }, rowSpan: { required: [undefined] }, @@ -334,7 +339,7 @@ const COLUMN_DEFINITION_VALIDATIONS: () => Validations = ( return validations; }; -type ColOrGroupKey = keyof ColDef | keyof ColGroupDef | keyof Partial; +type ColOrGroupKey = keyof ColDef | keyof ColGroupDef; const colDefPropertyMap: Record = { headerName: undefined, columnGroupShow: undefined, @@ -360,7 +365,6 @@ const colDefPropertyMap: Record = { initialAggFunc: undefined, defaultAggFunc: undefined, aggFunc: undefined, - // Internal properties (not public API but still accepted) groupRowEditable: undefined, groupRowValueSetter: undefined, pinned: undefined, diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index a7455296361..bd8301ea23b 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -9,13 +9,13 @@ import type { BeanCollection } from '../context/context'; import type { EditService } from '../edit/editService'; import type { AgColumn } from '../entities/agColumn'; import type { + ColDef, KeyCreatorParams, ValueFormatterParams, ValueGetterParams, ValueParserParams, ValueSetterParams, } from '../entities/colDef'; -import type { ColDefInternal } from '../entities/colDefInternal'; import type { RowNode } from '../entities/rowNode'; import type { CellValueChangedEvent } from '../events'; import { _addGridCommonParams, _isServerSideRowModel } from '../gridOptionsUtils'; @@ -418,7 +418,7 @@ export class ValueService extends BeanStub implements NamedBean { * @returns `true` if the value has been updated, otherwise `false`. */ public setValue(rowNode: IRowNode, column: AgColumn, newValue: any, eventSource?: string): boolean { - const colDef = column.getColDef() as ColDefInternal; + const colDef = column.getColDef(); if (!rowNode.data && this.canCreateRowNodeData(rowNode, colDef)) { rowNode.data = {}; // enableGroupEdit allows editing group rows without data. @@ -500,7 +500,7 @@ export class ValueService extends BeanStub implements NamedBean { return this.finishValueChange(rowNode, column, params, eventSource); } - private canCreateRowNodeData(rowNode: IRowNode, colDef: ColDefInternal): boolean { + private canCreateRowNodeData(rowNode: IRowNode, colDef: ColDef): boolean { if (!rowNode.group) { return true; // not a group row } diff --git a/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts index 21c81ad5ab4..4c07772bc72 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-edit-clipboard-paste.test.ts @@ -13,7 +13,6 @@ import { waitForEvent, } from '../../test-utils'; import { expect } from '../../test-utils/matchers'; -import type { ColDefInternal } from './group-edit-test-utils'; describe('Group Edit: clipboard paste', () => { const gridMgr = new TestGridsManager({ @@ -65,7 +64,7 @@ describe('Group Edit: clipboard paste', () => { editable: true, groupRowEditable: true, valueSetter, - } as ColDefInternal, + }, { field: 'category', rowGroup: true, hide: true }, ], rowData: [ diff --git a/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts b/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts index e90a6061a44..8a43441d7f0 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts @@ -4,12 +4,6 @@ import type { ColDef, GridApi, IRowNode } from 'ag-grid-community'; import { AllCommunityModule, ClientSideRowModelModule, UndoRedoEditModule } from 'ag-grid-community'; import { RowGroupingModule, SetFilterModule, TreeDataModule } from 'ag-grid-enterprise'; -import type { - ColDefInternal, - GroupRowEditableCallback as GroupRowEditableCallbackInternal, - GroupRowValueSetterFunc, - GroupRowValueSetterParams, -} from '../../../../../packages/ag-grid-community/src/entities/colDefInternal'; import { TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; import { expect } from '../../test-utils/matchers'; @@ -27,14 +21,11 @@ export const gridsManager = new TestGridsManager({ export const EDIT_MODES = ['ui', 'setDataValue'] as const; export type EditableCallback = Exclude, boolean>; -export type GroupRowEditableCallback = GroupRowEditableCallbackInternal; -export type GroupRowValueSetterCallback = GroupRowValueSetterFunc; +export type GroupRowEditableCallback = Exclude, boolean>; +export type GroupRowValueSetterCallback = Extract, (...args: any[]) => any>; export type ValueSetterCallback = Extract, (...args: any[]) => any>; export type ValueParserCallback = Extract, (...args: any[]) => any>; -// Re-export internal types for tests -export type { ColDefInternal, GroupRowValueSetterParams }; - function locateCellElements(api: GridApi, rowNode: IRowNode, colId: string) { const gridDiv = TestGridsManager.getHTMLElement(api); expect(gridDiv).not.toBeNull(); diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-pivot.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-pivot.test.ts index e08ca5efe58..05842423d63 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-pivot.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-pivot.test.ts @@ -5,7 +5,6 @@ import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; import type { GridRowsOptions } from '../../test-utils'; import { GridRows, TestGridsManager, asyncSetTimeout } from '../../test-utils'; import { expect } from '../../test-utils/matchers'; -import type { ColDefInternal } from './group-edit-test-utils'; import { EDIT_MODES, cascadeGroupRowValueSetter, editCell } from './group-edit-test-utils'; interface PivotRowData { @@ -64,7 +63,7 @@ describe('editing with pinned pivot rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -265,7 +264,7 @@ describe('editing with pinned pivot rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -452,7 +451,7 @@ describe('editing with pinned pivot rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], isRowPinned: (node) => { if (!node.group) { diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts index c34c39760f4..98644507935 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pinned-sibling.test.ts @@ -1,9 +1,8 @@ -import type { GridOptions, RowNode, ValueSetterParams } from 'ag-grid-community'; +import type { GridOptions, GroupRowValueSetterParams, RowNode, ValueSetterParams } from 'ag-grid-community'; import { ClientSideRowModelModule, PinnedRowModule, UndoRedoEditModule } from 'ag-grid-community'; import { PivotModule, RowGroupingModule, SetFilterModule } from 'ag-grid-enterprise'; import { GridRows, TestGridsManager, asyncSetTimeout } from '../../test-utils'; -import type { ColDefInternal, GroupRowValueSetterParams } from './group-edit-test-utils'; import { EDIT_MODES, cascadeGroupRowValueSetter, editCell } from './group-edit-test-utils'; /** @@ -218,7 +217,7 @@ describe('editing with pinned sibling rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], rowData: createGroupRowData(), groupDefaultExpanded: -1, @@ -307,7 +306,7 @@ describe('editing with pinned sibling rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], rowData: createGroupRowData(), groupDefaultExpanded: -1, @@ -389,7 +388,7 @@ describe('editing with pinned sibling rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: customValueSetter, - } as ColDefInternal, + }, ], rowData: createGroupRowDataForCallback(), groupDefaultExpanded: -1, @@ -446,7 +445,7 @@ describe('editing with pinned sibling rows', () => { groupRowEditable: true, groupRowValueSetter: customValueSetter, filter: 'agNumberColumnFilter', - } as ColDefInternal, + }, ], rowData: createGroupRowDataForCallback(), groupDefaultExpanded: -1, @@ -509,7 +508,7 @@ describe('editing with pinned sibling rows', () => { editable: true, groupRowEditable: true, groupRowValueSetter: customValueSetter, - } as ColDefInternal, + }, ], rowData: createGroupRowDataForCallback(), groupDefaultExpanded: -1, diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pivot.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pivot.test.ts index 2f426393385..fbfa26a2469 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pivot.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-pivot.test.ts @@ -6,7 +6,6 @@ import type { GridRowsOptions } from '../../test-utils'; import { EditEventTracker, GridRows, TestGridsManager, asyncSetTimeout } from '../../test-utils'; import { expect } from '../../test-utils/matchers'; import type { - ColDefInternal, GroupRowEditableCallback, GroupRowValueSetterCallback, ValueSetterCallback, @@ -68,7 +67,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable, groupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -148,7 +147,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -221,7 +220,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -299,7 +298,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -375,7 +374,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -431,7 +430,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -489,7 +488,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -556,7 +555,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -647,7 +646,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -727,7 +726,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -820,7 +819,7 @@ describe('groupRowEditable with pivot mode', () => { groupRowEditable: true, valueSetter, groupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -970,7 +969,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: -1, @@ -1074,7 +1073,7 @@ describe('groupRowEditable with pivot mode', () => { editable: true, groupRowEditable: true, groupRowValueSetter: cascadeGroupRowValueSetter, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: 0, @@ -1143,7 +1142,7 @@ describe('groupRowEditable with pivot mode', () => { hide: true, editable: true, groupRowEditable: true, - } as ColDefInternal, + }, ], pivotMode: true, groupDefaultExpanded: 0, diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts index dee44e513c5..2a9758d6a07 100644 --- a/testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts @@ -2,7 +2,6 @@ import type { GridOptions, ValueParserParams } from 'ag-grid-community'; import { expect } from '../../test-utils/matchers'; import type { - ColDefInternal, EditableCallback, GroupRowEditableCallback, GroupRowValueSetterCallback, @@ -67,7 +66,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { editable, groupRowEditable, valueSetter, - } as ColDefInternal, + }, { field: 'category', rowGroup: true, hide: true }, ], rowData: [ @@ -90,7 +89,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { editableCalls.length = 0; valueSetterCalls.length = 0; const groupColumn = api.getDisplayedCenterColumns()[0]!; - expect((groupColumn.getColDef() as ColDefInternal).groupRowEditable).toBe(groupRowEditable); + expect(groupColumn.getColDef().groupRowEditable).toBe(groupRowEditable); expect(groupColumn.isCellEditable(groupRowNode!)).toBe(true); const groupColId = groupColumn.getColId(); if (editMode === 'ui') { @@ -191,7 +190,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { groupRowEditable: true, valueParser, valueSetter, - } as ColDefInternal, + }, { field: 'category', rowGroup: true, hide: true }, ], rowData: [ @@ -264,7 +263,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { editable, groupRowEditable, valueSetter, - } as ColDefInternal, + }, ], treeData: true, rowData: [{ id: 'mars', path: ['Solar System', 'Mars'], label: 'Mars' }], @@ -280,7 +279,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { const originalFillerValue = getGroupColumnDisplayValue(fillerRowNode!); const groupColumn = api.getDisplayedCenterColumns()[0]!; - expect((groupColumn.getColDef() as ColDefInternal).groupRowEditable).toBe(groupRowEditable); + expect(groupColumn.getColDef().groupRowEditable).toBe(groupRowEditable); groupRowEditableCalls.length = 0; editableCalls.length = 0; @@ -351,7 +350,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { editable, groupRowEditable, valueSetter, - } as ColDefInternal, + }, ], treeData: true, rowData, @@ -485,7 +484,7 @@ describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { groupRowEditable: false, valueSetter, groupRowValueSetter, - } as ColDefInternal, + }, { field: 'category', rowGroup: true, hide: true }, ], rowData: [