From 25f15126d9b724f83f7c5abb329456c8d89df267 Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Mon, 19 Jan 2026 21:10:36 +0000 Subject: [PATCH] AG-16541 group row editable (#12898) * AG-16541-group-row-editable --- .../group-row-editable-totals/data.ts | 21 + .../group-row-editable-totals/example.spec.ts | 8 + .../group-row-editable-totals/index.html | 1 + .../group-row-editable-totals/main.ts | 108 ++++ .../src/content/docs/grouping-edit/index.mdoc | 58 +- .../src/columns/dataTypeService.ts | 4 +- .../ag-grid-community/src/edit/editService.ts | 7 +- .../src/edit/strategy/strategyUtils.ts | 56 +- .../src/edit/utils/editors.ts | 2 +- .../ag-grid-community/src/entities/colDef.ts | 34 ++ packages/ag-grid-community/src/main.ts | 4 + .../src/validation/rules/colDefValidations.ts | 14 +- .../src/valueService/valueService.ts | 107 ++-- .../src/rowHierarchy/groupEditService.ts | 5 +- ...l-editing-refresh-after-group-edit.test.ts | 70 ++- .../group-edit/group-edit-test-utils.ts | 116 ++++ .../group-row-editable-aggregation.test.ts | 364 +++++++++++++ .../group-edit/group-row-editable.test.ts | 508 ++++++++++++++++++ .../src/filters/aggregate-filters.test.ts | 4 +- 19 files changed, 1416 insertions(+), 75 deletions(-) create mode 100644 documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/data.ts create mode 100644 documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/example.spec.ts create mode 100644 documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/index.html create mode 100644 documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts create mode 100644 testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts create mode 100644 testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation.test.ts create mode 100644 testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/data.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/data.ts new file mode 100644 index 00000000000..d3975ddec45 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/data.ts @@ -0,0 +1,21 @@ +export const getData = () => [ + { 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: '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: '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: 'ca-toronto', region: 'Americas', segment: 'Enterprise', country: 'Canada', amount: 35 }, + { id: 'ca-vancouver', region: 'Americas', segment: 'Enterprise', country: 'Canada', amount: 25 }, + { 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 }, +]; diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/example.spec.ts new file mode 100644 index 00000000000..2c0eed06460 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/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-row-editable-totals/index.html b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/index.html new file mode 100644 index 00000000000..6c46dc75f2e --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/index.html @@ -0,0 +1 @@ +
diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts new file mode 100644 index 00000000000..bd448f2d54f --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts @@ -0,0 +1,108 @@ +import type { GridOptions, GroupRowValueSetterFunc, ValueFormatterParams, ValueParserParams } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + ModuleRegistry, + NumberFilterModule, + ValidationModule, + createGrid, +} from 'ag-grid-community'; +import { RowGroupingModule, SetFilterModule } from 'ag-grid-enterprise'; + +import { getData } from './data'; + +interface SalesRecord { + id: string; + region: string; + segment: string; + country: string; + amount: number; +} + +ModuleRegistry.registerModules([ + RowGroupingModule, + ClientSideRowModelModule, + NumberFilterModule, + SetFilterModule, + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), +]); + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); + +const amountValueFormatter = (params: ValueFormatterParams): string => + typeof params.value === 'number' ? currencyFormatter.format(params.value) : params.value ?? ''; + +const amountValueParser = (params: ValueParserParams): number | null => { + const numericValue = Number(params.newValue); + return Number.isFinite(numericValue) ? numericValue : params.oldValue ?? null; +}; + +const amountGroupRowValueSetter: GroupRowValueSetterFunc = ({ node, newValue, eventSource }) => { + const numericValue = Number(newValue); + if (!Number.isFinite(numericValue)) { + return false; + } + + let result = false; + // distribute the new value equally amongst all filtered children + const children = node.childrenAfterSort; + if (children?.length) { + const perChild = numericValue / children.length; + for (const child of children) { + // If child is a leaf, setDataValue will update the underlying data item + // If child is a group, setDataValue will recursively call this value setter down the tree to update group values + if (child.setDataValue('amount', perChild, eventSource)) { + result = true; + } + } + } + return result; +}; + +const gridOptions: GridOptions = { + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'segment', rowGroup: true, hide: true, filter: 'agSetColumnFilter' }, + { field: 'country', filter: 'agSetColumnFilter' }, + { + headerName: 'Annual Budget', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + filter: 'agNumberColumnFilter', + valueParser: amountValueParser, + groupRowValueSetter: amountGroupRowValueSetter, + valueFormatter: amountValueFormatter, + }, + ], + 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'); + if (!gridDiv) { + return; + } + 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 85d4d629cb2..b47bd767f95 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 @@ -3,23 +3,65 @@ title: "Row Grouping - Editing Groups" enterprise: true --- -The Grid supports editing of grouped data when using the [Client-Side Row Model](./row-models/#client-side). -When users edit values in grouped columns, the grid can automatically refresh the grouping hierarchy to reflect the changes. +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 and how to allow group rows themselves to be editable. ## Refreshing Groups After Editing -When grouped columns are editable, enable `refreshAfterGroupEdit=true` to update the row data and regroup after each group field edit. +When grouped columns are editable, set `refreshAfterGroupEdit=true` so the grid updates row data +and recalculates the grouping after every committed edit. {% gridExampleRunner title="Refresh After Group Edit" name="refresh-after-group-edit" exampleHeight=600 /%} -In the example above, changing the `region` causes the grid to re-evaluate the grouping and move the row to the correct group instantly. -Double click on a `region` cell to edit it. +In the example above, changing the `region` causes the grid to re-evaluate the grouping and move +the row to the correct group instantly. Double click on a `region` cell to edit it. -By default, without `refreshAfterGroupEdit` enabled, the row data updates but the grouping does not get updated until the next full refresh. +By default, without `refreshAfterGroupEdit` enabled, the row data updates but the grouping does not +get updated until the next full refresh. -When enabling `refreshAfterGroupEdit`, it is recommended to also set `getRowId` so that the grid can track rows by stable IDs while rebuilding the grouping hierarchy. +When enabling `refreshAfterGroupEdit`, also provide `getRowId` so that the grid can track rows by +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. +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="Editable Group Totals" name="group-row-editable-totals" exampleHeight=620 /%} + +Key points: + +- Use `groupRowValueSetter` to redistribute committed values to descendants. + The callback receives the group row node and column. +- Call `rowNode.setDataValue` on children to push the updated figures down. This triggers normal + aggregation to refresh parent totals and recurses further when a child is also a group that defines + its own `groupRowValueSetter`. +- `rowNode.childrenAfterSort` contains the descendants in the node, groups or leaf rows. +- Parent aggregates refresh automatically because child `data` changes re-run the column `aggFunc`, + so typing £600 into “Europe” instantly rebalances the filtered countries to reach that total. ## Next Up Continue to the next section to learn [Row Dragging with Row Groups](./grouping-row-dragging/). diff --git a/packages/ag-grid-community/src/columns/dataTypeService.ts b/packages/ag-grid-community/src/columns/dataTypeService.ts index 8f1157c601a..79f90016b85 100644 --- a/packages/ag-grid-community/src/columns/dataTypeService.ts +++ b/packages/ag-grid-community/src/columns/dataTypeService.ts @@ -542,8 +542,8 @@ export class DataTypeService extends BeanStub implements NamedBean { cellEditor: 'agCheckboxCellEditor', cellRenderer: 'agCheckboxCellRenderer', getFindText: () => null, - suppressKeyboardEvent: (params: SuppressKeyboardEventParams) => - !!params.colDef.editable && params.event.key === KeyCode.SPACE, + suppressKeyboardEvent: ({ node, event, column }: SuppressKeyboardEventParams) => + event.key === KeyCode.SPACE && column.isCellEditable(node), }; }, date({ formatValue }) { diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index 8b60178af8a..4e38d80dbc9 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -718,9 +718,10 @@ export class EditService extends BeanStub implements NamedBean, IEditService { } public isCellEditable(position: Required, source: 'api' | 'ui' = 'ui'): boolean { - const { rowNode } = position; const { gos, beans } = this; - if (rowNode.group) { + + const rowNode = position.rowNode; + 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. @@ -738,7 +739,7 @@ export class EditService extends BeanStub implements NamedBean, IEditService { const isEditable = getEditType(gos) === 'fullRow' ? isFullRowCellEditable(beans, position, source) - : isCellEditable(beans, position, source); + : isCellEditable(beans, position); if (isEditable) { this.strategy ??= this.createStrategy(); diff --git a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts index 7d5dd80e621..e2dcd4fca23 100644 --- a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts +++ b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts @@ -66,17 +66,36 @@ function deriveClickCount(gos: GridOptionsService, colDef?: ColDef): number { return 2; } -export function isCellEditable( - beans: BeanCollection, - { rowNode, column }: Required, - _source: 'api' | 'ui' = 'ui' -): boolean { - const editable = column.getColDef().editable; - const editModelSvc = beans.editModelSvc; - return ( - (column as AgColumn).isColumnFunc(rowNode, editable) || - (!!editModelSvc && editModelSvc.hasEdits({ rowNode, column }, { withOpenEditor: true })) - ); +function existingEditing(beans: BeanCollection, editPosition: Required): boolean { + return beans.editModelSvc?.hasEdits(editPosition, { withOpenEditor: true }) ?? false; +} + +export function isCellEditable(beans: BeanCollection, editPosition: Required): boolean { + const column = editPosition.column as AgColumn; + const rowNode = editPosition.rowNode; + const colDef = column.getColDef(); + + if (!rowNode) { + return existingEditing(beans, editPosition); + } + + const editable = colDef.editable; + + if (rowNode.group) { + const groupRowEditable = colDef.groupRowEditable; + if (groupRowEditable != null) { + if (column.isColumnFunc(rowNode, groupRowEditable)) { + return true; + } + return existingEditing(beans, editPosition); + } + } + + if (column.isColumnFunc(rowNode, editable)) { + return true; + } + + return existingEditing(beans, editPosition); } export function isFullRowCellEditable( @@ -84,13 +103,18 @@ export function isFullRowCellEditable( position: Required, source: 'api' | 'ui' = 'ui' ): boolean { - const editable = isCellEditable(beans, position, source); - - if (editable === true || source === 'ui') { + const editable = isCellEditable(beans, position); + if (editable || source === 'ui') { return editable; } // check if other cells in row are editable, so starting edit on uneditable cell will still work - const columns = beans.colModel.getCols(); - return columns.some((col: AgColumn) => isCellEditable(beans, { rowNode: position.rowNode, column: col }, source)); + const { rowNode, column } = position; + for (const col of beans.colModel.getCols()) { + if (col !== column && isCellEditable(beans, { rowNode, column: col })) { + return true; + } + } + + return false; } diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index 995fd0b05f7..825e039d077 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -548,7 +548,7 @@ function _columnDefsRequireValidation(columnDefs?: ColDef[]): boolean { for (let i = 0, len = columnDefs.length; i < len; ++i) { const colDef = columnDefs[i]; const params = colDef.cellEditorParams; - if (!params || !colDef.editable) { + if (!params || (!colDef.editable && !colDef.groupRowEditable)) { continue; } if ( diff --git a/packages/ag-grid-community/src/entities/colDef.ts b/packages/ag-grid-community/src/entities/colDef.ts index 039c33ac8c5..5a31503f697 100644 --- a/packages/ag-grid-community/src/entities/colDef.ts +++ b/packages/ag-grid-community/src/entities/colDef.ts @@ -385,9 +385,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. */ valueSetter?: string | ValueSetterFunc; /** Function or expression. Parses the value for saving. */ @@ -955,6 +967,28 @@ 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; +} +export type GroupRowValueSetterFunc = ( + params: GroupRowValueSetterParams +) => void | boolean | undefined; export interface SuppressPasteCallbackParams extends ColumnFunctionCallbackParams {} export type SuppressPasteCallback = ( diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 3749d552d9e..021b830c7ca 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -695,6 +695,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 fe66e185b69..9176daa8d4c 100644 --- a/packages/ag-grid-community/src/validation/rules/colDefValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/colDefValidations.ts @@ -39,8 +39,9 @@ export const COLUMN_DEFINITION_MOD_VALIDATIONS: ModuleValidation { - if (!editable) { + cellEditor: ({ cellEditor, editable, groupRowEditable }: ColDef) => { + const editingEnabled = !!editable || !!groupRowEditable; + if (!editingEnabled) { return null; } if (typeof cellEditor === 'string') { @@ -65,6 +66,12 @@ export const COLUMN_DEFINITION_MOD_VALIDATIONS: ModuleValidation { + if (groupRowEditable && !cellEditor) { + return 'TextEditor'; + } + return null; + }, enableCellChangeFlash: 'HighlightChanges', enablePivot: 'SharedPivot', enableRowGroup: 'SharedRowGrouping', @@ -253,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] }, @@ -357,6 +365,8 @@ const colDefPropertyMap: Record = { initialAggFunc: undefined, defaultAggFunc: undefined, aggFunc: undefined, + groupRowEditable: undefined, + groupRowValueSetter: undefined, pinned: undefined, initialPinned: undefined, chartDataType: undefined, diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index f92c760befd..07854a0f84b 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -8,6 +8,7 @@ import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; import type { + ColDef, KeyCreatorParams, ValueFormatterParams, ValueGetterParams, @@ -401,60 +402,99 @@ export class ValueService extends BeanStub implements NamedBean { if (!rowNode || !column) { return false; } - this.ensureRowData(rowNode); const colDef = column.getColDef(); + + if (!rowNode.data && this.canCreateRowNodeData(rowNode, colDef)) { + rowNode.data = {}; // enableGroupEdit allows editing group rows without data. + } + if (!this.isSetValueSupported({ column, newValue, colDef })) { return false; } + const oldValue = this.getValue(column, rowNode, undefined, eventSource); + const params: ValueSetterParams = _addGridCommonParams(this.gos, { node: rowNode, data: rowNode.data, - oldValue: this.getValue(column, rowNode, undefined, eventSource), + oldValue, newValue: newValue, colDef, column: column, }); - params.newValue = newValue; + const groupRowValueSetter = rowNode.group ? colDef.groupRowValueSetter : undefined; - const externalFormulaResult = this.handleExternalFormulaChange({ - column, - eventSource, - newValue, - setterParams: params, - rowNode, - }); - if (externalFormulaResult !== null) { - return externalFormulaResult; + let valueSetterChanged = false; + let groupRowValueSetterChanged = false; + + if (rowNode.data) { + const externalFormulaResult = this.handleExternalFormulaChange({ + column, + eventSource, + newValue, + setterParams: params, + rowNode, + }); + if (externalFormulaResult !== null) { + return externalFormulaResult; + } + + const result = this.computeValueChange({ + column, + rowNode, + newValue, + params, + rowData: rowNode.data, + valueSetter: colDef.valueSetter, + field: colDef.field, + }); + + // default to true if user forgot to return a value (possible without TypeScript) + valueSetterChanged = result ?? true; } - let valueWasDifferent = this.computeValueChange({ - column, - newValue, - params, - rowData: rowNode.data, - valueSetter: colDef.valueSetter, - field: colDef.field, - }); + if (groupRowValueSetter) { + const result = groupRowValueSetter( + _addGridCommonParams(this.gos, { + node: rowNode, + data: rowNode.data, + oldValue, + newValue, + colDef, + column, + eventSource, + valueChanged: valueSetterChanged || newValue !== oldValue, + }) + ); - // in case user forgot to return something (possible if they are not using TypeScript - // and just forgot we default the return value to true, so we always refresh. - if (valueWasDifferent === undefined) { - valueWasDifferent = true; + // default to true if user forgot to return a value (possible without TypeScript) + groupRowValueSetterChanged = result ?? true; } - // if no change to the value, then no need to do the updating, or notifying via events. - // otherwise the user could be tabbing around the grid, and cellValueChange would get called - // all the time. - if (!valueWasDifferent) { + if (!valueSetterChanged && !groupRowValueSetterChanged) { + // if no change to the value, then no need to do the updating, or notifying via events. + // otherwise the user could be tabbing around the grid, and cellValueChange would get called + // all the time. return false; } return this.finishValueChange(rowNode, column, params, eventSource); } + private canCreateRowNodeData(rowNode: IRowNode, colDef: ColDef): boolean { + if (!rowNode.group) { + return true; // not a group row + } + + if (colDef.groupRowEditable != null || colDef.groupRowValueSetter != null) { + return false; // Do not create the row data for a group row automatically + } + + return true; // create the rowData for groupRowEditable (default legacy behaviour) + } + private finishValueChange( rowNode: IRowNode, column: AgColumn, @@ -476,13 +516,6 @@ export class ValueService extends BeanStub implements NamedBean { return true; } - private ensureRowData(rowNode: IRowNode): void { - // enableGroupEdit allows editing group rows without data. - if (_missing(rowNode.data)) { - rowNode.data = {}; - } - } - private isSetValueSupported(params: { column: AgColumn; newValue: any; @@ -540,6 +573,7 @@ export class ValueService extends BeanStub implements NamedBean { const computedParams: ValueSetterParams = { ...setterParams, newValue: computedValue }; this.computeValueChange({ column, + rowNode, newValue: computedValue, params: computedParams, rowData: rowNode.data, @@ -563,6 +597,7 @@ export class ValueService extends BeanStub implements NamedBean { params: ValueSetterParams; rowData: any; field: string | undefined; + rowNode: IRowNode; column: AgColumn; newValue: any; }): boolean | undefined { @@ -575,7 +610,7 @@ export class ValueService extends BeanStub implements NamedBean { return this.expressionSvc?.evaluate(valueSetter, setterParams); } - return this.setValueUsingField(rowData, field, newValue, column.isFieldContainsDots()); + return !!rowData && this.setValueUsingField(rowData, field, newValue, column.isFieldContainsDots()); } private dispatchCellValueChangedEvent( diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts index 016dfd28d9f..3075f3e76de 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts @@ -462,7 +462,7 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { if (maxLevel < 0) { return false; } - const { valueSvc, editSvc } = this.beans; + const { valueSvc } = this.beans; let changed = false; for (let level = 0; level < columns.length; ++level) { const column = columns[level]; @@ -479,8 +479,7 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { if (parsedValue !== undefined) { valueToSet = parsedValue; } - const result = editSvc?.setDataValue({ rowNode: row, column }, valueToSet, 'rowDrag'); - const updated = result != null ? !!result : row.setDataValue(column, valueToSet, 'rowDrag'); + const updated = row.setDataValue(column, valueToSet, 'rowDrag'); if (updated) { changed = true; } diff --git a/testing/behavioural/src/cell-editing/cell-editing-refresh-after-group-edit.test.ts b/testing/behavioural/src/cell-editing/cell-editing-refresh-after-group-edit.test.ts index fa514cbcab1..ca2412ce6af 100644 --- a/testing/behavioural/src/cell-editing/cell-editing-refresh-after-group-edit.test.ts +++ b/testing/behavioural/src/cell-editing/cell-editing-refresh-after-group-edit.test.ts @@ -1,6 +1,6 @@ import { userEvent } from '@testing-library/user-event'; -import { ClientSideRowModelModule } from 'ag-grid-community'; +import { ClientSideRowModelModule, UndoRedoEditModule } from 'ag-grid-community'; import type { GridOptions } from 'ag-grid-community'; import { BatchEditModule, RowGroupingModule } from 'ag-grid-enterprise'; @@ -8,7 +8,7 @@ import { GridRows, TestGridsManager, asyncSetTimeout, waitForInput } from '../te describe('cell editing with refreshAfterGroupEdit', () => { const gridsManager = new TestGridsManager({ - modules: [ClientSideRowModelModule, RowGroupingModule, BatchEditModule], + modules: [ClientSideRowModelModule, RowGroupingModule, BatchEditModule, UndoRedoEditModule], }); beforeEach(() => { @@ -151,4 +151,70 @@ describe('cell editing with refreshAfterGroupEdit', () => { expect(api.getRowNode('2')?.parent?.key).toBe('B'); expect(api.getRowNode('3')?.parent?.key).toBe('A'); }); + + test('aggregation columns refresh when rows move', async () => { + const gridOptions: GridOptions = { + animateRows: true, + columnDefs: [ + { field: 'group', rowGroup: true, editable: true }, + { field: 'value', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { + field: 'group', + cellRendererParams: { + suppressDoubleClickExpand: true, + }, + }, + rowData: [ + { id: '1', group: 'A', value: 10 }, + { id: '2', group: 'A', value: 15 }, + { id: '3', group: 'B', value: 7 }, + ], + refreshAfterGroupEdit: true, + undoRedoCellEditing: true, + enableGroupEdit: true, + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = gridsManager.createGrid('cell-edit-refresh-group-aggregation', gridOptions); + await asyncSetTimeout(0); + + const gridDiv = TestGridsManager.getHTMLElement(api)!; + const editGroupCell = async (rowId: string, value: string) => { + const cell = gridDiv.querySelector(`[row-id="${rowId}"] [col-id="group"]`); + expect(cell).not.toBeNull(); + + await userEvent.dblClick(cell!); + const input = await waitForInput(gridDiv, cell!); + await userEvent.clear(input); + await userEvent.type(input, `${value}{Enter}`); + await asyncSetTimeout(0); + }; + + const initialSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:25 + │ ├── LEAF id:1 ag-Grid-AutoColumn:"A" group:"A" value:10 + │ └── LEAF id:2 ag-Grid-AutoColumn:"A" group:"A" value:15 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:7 + · └── LEAF id:3 ag-Grid-AutoColumn:"B" group:"B" value:7 + `; + + const afterEditSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:10 + │ └── LEAF id:1 ag-Grid-AutoColumn:"A" group:"A" value:10 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:22 + · ├── LEAF id:2 ag-Grid-AutoColumn:"B" group:"B" value:15 + · └── LEAF id:3 ag-Grid-AutoColumn:"B" group:"B" value:7 + `; + + await new GridRows(api, 'aggregation initial', { useFormatter: false }).check(initialSnapshot); + + await editGroupCell('2', 'B'); + await asyncSetTimeout(2); + + await new GridRows(api, 'aggregation after edit', { useFormatter: false }).check(afterEditSnapshot); + }); }); 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 new file mode 100644 index 00000000000..4df97173b5c --- /dev/null +++ b/testing/behavioural/src/cell-editing/group-edit/group-edit-test-utils.ts @@ -0,0 +1,116 @@ +import { userEvent } from '@testing-library/user-event'; + +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 { TestGridsManager, asyncSetTimeout, waitForInput } from '../../test-utils'; +import { expect } from '../../test-utils/matchers'; + +export const gridsManager = new TestGridsManager({ + modules: [ + AllCommunityModule, + ClientSideRowModelModule, + RowGroupingModule, + TreeDataModule, + UndoRedoEditModule, + SetFilterModule, + ], +}); + +export const EDIT_MODES = ['ui', 'setDataValue'] as const; + +export type EditableCallback = Exclude, boolean>; +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>; + +function locateCellElements(api: GridApi, rowNode: IRowNode, colId: string) { + const gridDiv = TestGridsManager.getHTMLElement(api); + expect(gridDiv).not.toBeNull(); + + const rowId = rowNode.id; + expect(rowId).toBeDefined(); + + const rowIndex = rowNode.rowIndex; + expect(rowIndex).not.toBeNull(); + + let cell = gridDiv!.querySelector(`[row-id="${rowId}"] [col-id="${colId}"]`); + if (!cell && rowIndex != null) { + const rowElement = gridDiv!.querySelector(`.ag-row[aria-rowindex="${rowIndex + 1}"]`); + cell = rowElement?.querySelector(`[col-id="${colId}"]`) ?? null; + } + expect(cell).not.toBeNull(); + + return { gridDiv: gridDiv!, cell: cell!, rowIndex: rowIndex! }; +} + +export async function editCell(api: GridApi, rowNode: IRowNode, colId: string, newValue: string) { + const { gridDiv, cell, rowIndex } = locateCellElements(api, rowNode, colId); + + await userEvent.click(cell); + + api.setFocusedCell(rowIndex, colId); + api.startEditingCell({ rowIndex, rowPinned: rowNode.rowPinned, colKey: colId }); + + const input = await waitForInput(gridDiv, cell ?? gridDiv); + await userEvent.clear(input); + await userEvent.type(input, `${newValue}{Enter}`); + await asyncSetTimeout(0); + + return cell; +} + +export function getGroupColumnDisplayValue(rowNode: IRowNode): string | undefined { + const groupValue = rowNode.groupData?.group; + if (groupValue !== undefined) { + return groupValue; + } + const data = rowNode.data as { label?: string } | undefined; + return data?.label; +} + +export type CallbackArgs = + | Parameters + | Parameters + | Parameters; + +export function callsForRowNode(calls: CallbackArgs[], rowId?: string | null) { + if (!rowId) { + return [] as CallbackArgs[]; + } + return calls.filter(([params]) => params?.node?.id === rowId); +} + +export function createGroupRowData() { + return [ + { id: 'fr-paris', region: 'Europe', country: 'France', amount: 30 }, + { id: 'fr-lyon', region: 'Europe', country: 'France', amount: 30 }, + { id: 'de-berlin', region: 'Europe', country: 'Germany', amount: 30 }, + { id: 'de-hamburg', region: 'Europe', country: 'Germany', amount: 30 }, + { id: 'it-rome', region: 'Europe', country: 'Italy', amount: 30 }, + { id: 'it-milan', region: 'Europe', country: 'Italy', amount: 30 }, + { id: 'us-nyc', region: 'Americas', country: 'USA', amount: 70 }, + { id: 'us-la', region: 'Americas', country: 'USA', amount: 30 }, + { id: 'ca-toronto', region: 'Americas', country: 'Canada', amount: 35 }, + { id: 'ca-vancouver', region: 'Americas', country: 'Canada', amount: 25 }, + ]; +} + +export const cascadeGroupRowValueSetter: GroupRowValueSetterCallback = ({ node, column, newValue, eventSource }) => { + const numericValue = Number(newValue); + if (!Number.isFinite(numericValue)) { + return; + } + + const children = node.childrenAfterSort; + if (children) { + const perChild = numericValue / children.length; + for (const child of children) { + child.setDataValue(column, perChild, eventSource); + } + } +}; + +export { asyncSetTimeout }; diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation.test.ts new file mode 100644 index 00000000000..5f017f90321 --- /dev/null +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation.test.ts @@ -0,0 +1,364 @@ +import type { NumberFilterModel, SetFilterModel } from 'ag-grid-community'; + +import { GridRows } from '../../test-utils'; +import { expect } from '../../test-utils/matchers'; +import { + EDIT_MODES, + asyncSetTimeout, + cascadeGroupRowValueSetter, + createGroupRowData as createRowData, + editCell, + gridsManager, +} from './group-edit-test-utils'; + +afterEach(() => { + gridsManager.reset(); +}); + +describe.each(EDIT_MODES)('groupRowEditable cascading edits (%s)', (editMode) => { + const baselineSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:180 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:60 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:60 + │ │ ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:30 + │ │ └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:30 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Italy amount:60 + │ · ├── LEAF id:it-rome region:"Europe" country:"Italy" amount:30 + │ · └── LEAF id:it-milan region:"Europe" country:"Italy" amount:30 + └─┬ filler id:row-group-region-Americas amount:160 + · ├─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 + · │ ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:70 + · │ └── LEAF id:us-la region:"Americas" country:"USA" amount:30 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-Canada amount:60 + · · ├── LEAF id:ca-toronto region:"Americas" country:"Canada" amount:35 + · · └── LEAF id:ca-vancouver region:"Americas" country:"Canada" amount:25 + `; + + test('group edits cascade through descendants and refresh aggregations', async () => { + const rowData = createRowData(); + + const api = await gridsManager.createGridAndWait('group-row-editable-changed-path', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + cellRenderer: 'agGroupCellRenderer', + }, + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + groupRowValueSetter: cascadeGroupRowValueSetter, + }, + ], + rowData, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + await new GridRows(api, 'before edit').check(baselineSnapshot); + + const europeNode = api.getRowNode('row-group-region-Europe'); + expect(europeNode).toBeDefined(); + expect(europeNode!.data).toBeUndefined(); + + const amountColId = 'amount'; + const targetValue = 600; + + if (editMode === 'ui') { + await editCell(api, europeNode!, amountColId, `${targetValue}`); + } else { + europeNode!.setDataValue(amountColId, targetValue, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + expect(europeNode!.data).toBeUndefined(); + + const afterEditSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:600 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:200 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:100 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:100 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:200 + │ │ ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:100 + │ │ └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:100 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Italy amount:200 + │ · ├── LEAF id:it-rome region:"Europe" country:"Italy" amount:100 + │ · └── LEAF id:it-milan region:"Europe" country:"Italy" amount:100 + └─┬ filler id:row-group-region-Americas amount:160 + · ├─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 + · │ ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:70 + · │ └── LEAF id:us-la region:"Americas" country:"USA" amount:30 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-Canada amount:60 + · · ├── LEAF id:ca-toronto region:"Americas" country:"Canada" amount:35 + · · └── LEAF id:ca-vancouver region:"Americas" country:"Canada" amount:25 + `; + await new GridRows(api, 'after edit').check(afterEditSnapshot); + + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + await new GridRows(api, 'after undo').check(baselineSnapshot); + expect(europeNode!.aggData?.amount ?? 0).toBe(180); + } + }); + + test('editing a single leaf updates its parent aggregations', async () => { + const rowData = createRowData(); + + const api = await gridsManager.createGridAndWait('group-row-editable-leaf-edit', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + cellRenderer: 'agGroupCellRenderer', + }, + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + groupRowValueSetter: cascadeGroupRowValueSetter, + }, + ], + rowData, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + await new GridRows(api, 'before leaf edit').check(baselineSnapshot); + + const parisNode = api.getRowNode('fr-paris'); + expect(parisNode).toBeDefined(); + + const amountColId = 'amount'; + if (editMode === 'ui') { + await editCell(api, parisNode!, amountColId, '45'); + } else { + parisNode!.setDataValue(amountColId, 45, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + const snapshotAfterLeafEdit = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:195 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:75 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:45 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:60 + │ │ ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:30 + │ │ └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:30 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Italy amount:60 + │ · ├── LEAF id:it-rome region:"Europe" country:"Italy" amount:30 + │ · └── LEAF id:it-milan region:"Europe" country:"Italy" amount:30 + └─┬ filler id:row-group-region-Americas amount:160 + · ├─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 + · │ ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:70 + · │ └── LEAF id:us-la region:"Americas" country:"USA" amount:30 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-Canada amount:60 + · · ├── LEAF id:ca-toronto region:"Americas" country:"Canada" amount:35 + · · └── LEAF id:ca-vancouver region:"Americas" country:"Canada" amount:25 + `; + await new GridRows(api, 'after leaf edit').check(snapshotAfterLeafEdit); + + await asyncSetTimeout(0); + + const europeNode = api.getRowNode('row-group-region-Europe'); + expect(europeNode?.data).toBeUndefined(); + expect(europeNode?.aggData?.amount ?? 0).toBe(195); + + const franceGroupNode = api.getRowNode('row-group-region-Europe-country-France'); + expect(franceGroupNode?.aggData?.amount ?? 0).toBe(75); + + expect(api.getRowNode('fr-paris')?.data?.amount).toBe(45); + expect(api.getRowNode('fr-lyon')?.data?.amount).toBe(30); + }); + + test('group edits over filtered groups only adjust filtered descendants', async () => { + const rowData = createRowData(); + + const api = await gridsManager.createGridAndWait('group-row-editable-filtered', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + groupAggFiltering: true, + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + cellRenderer: 'agGroupCellRenderer', + }, + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true, filter: 'agSetColumnFilter' }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + filter: 'agNumberColumnFilter', + groupRowValueSetter: cascadeGroupRowValueSetter, + }, + ], + rowData, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + const filterModel: Record = { + country: { + filterType: 'set', + values: ['France', 'Germany'], + } as SetFilterModel, + amount: { + filterType: 'number', + type: 'greaterThan', + filter: 100, + } as NumberFilterModel, + }; + api.setFilterModel(filterModel); + await asyncSetTimeout(0); + + const filteredSnapshotBeforeEdit = ` + ROOT id:ROOT_NODE_ID + └─┬ filler id:row-group-region-Europe amount:180 + · ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:60 + · │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 + · │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 + · └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:60 + · · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:30 + · · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:30 + `; + await new GridRows(api, 'after applying filters').check(filteredSnapshotBeforeEdit); + + const europeNode = api.getRowNode('row-group-region-Europe'); + expect(europeNode).toBeDefined(); + expect(europeNode!.data).toBeUndefined(); + + const amountColId = 'amount'; + if (editMode === 'ui') { + await editCell(api, europeNode!, amountColId, '240'); + } else { + europeNode!.setDataValue(amountColId, 240, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + const filteredSnapshotAfterEdit = ` + ROOT id:ROOT_NODE_ID + └─┬ filler id:row-group-region-Europe amount:300 + · ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:120 + · │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:60 + · │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:60 + · └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:120 + · · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:60 + · · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:60 + `; + // Aggregated value still reflects hidden Italy nodes even though the filter hides them. + await new GridRows(api, 'after filtered edit').check(filteredSnapshotAfterEdit); + expect(api.getRowNode('it-rome')?.data?.amount).toBe(30); + expect(api.getRowNode('it-milan')?.data?.amount).toBe(30); + + api.setFilterModel(null); + await asyncSetTimeout(0); + + const fullSnapshotAfterClearing = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:300 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:60 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:120 + │ │ ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:60 + │ │ └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Italy amount:60 + │ · ├── LEAF id:it-rome region:"Europe" country:"Italy" amount:30 + │ · └── LEAF id:it-milan region:"Europe" country:"Italy" amount:30 + └─┬ filler id:row-group-region-Americas amount:160 + · ├─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 + · │ ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:70 + · │ └── LEAF id:us-la region:"Americas" country:"USA" amount:30 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-Canada amount:60 + · · ├── LEAF id:ca-toronto region:"Americas" country:"Canada" amount:35 + · · └── LEAF id:ca-vancouver region:"Americas" country:"Canada" amount:25 + `; + await new GridRows(api, 'after clearing filters').check(fullSnapshotAfterClearing); + + api.setFilterModel(filterModel); + await asyncSetTimeout(0); + await new GridRows(api, 'after reapplying filters').check(filteredSnapshotAfterEdit); + }); + + test('groupRowValueSetter returning false cancels the edit', async () => { + const rowData = createRowData(); + + const api = await gridsManager.createGridAndWait('group-row-editable-cancelled', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + cellRenderer: 'agGroupCellRenderer', + }, + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + groupRowValueSetter: () => false, + }, + ], + rowData, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + await new GridRows(api, 'before cancelled edit').check(baselineSnapshot); + + const europeNode = api.getRowNode('row-group-region-Europe'); + expect(europeNode).toBeDefined(); + + const amountColId = 'amount'; + if (editMode === 'ui') { + await editCell(api, europeNode!, amountColId, '999'); + } else { + europeNode!.setDataValue(amountColId, 999, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + await new GridRows(api, 'after cancelled edit').check(baselineSnapshot); + }); +}); 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 new file mode 100644 index 00000000000..2a9758d6a07 --- /dev/null +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable.test.ts @@ -0,0 +1,508 @@ +import type { GridOptions, ValueParserParams } from 'ag-grid-community'; + +import { expect } from '../../test-utils/matchers'; +import type { + EditableCallback, + GroupRowEditableCallback, + GroupRowValueSetterCallback, + ValueParserCallback, + ValueSetterCallback, +} from './group-edit-test-utils'; +import { + EDIT_MODES, + asyncSetTimeout, + callsForRowNode, + editCell, + getGroupColumnDisplayValue, + gridsManager, +} from './group-edit-test-utils'; + +afterEach(() => { + gridsManager.reset(); +}); + +describe.each(EDIT_MODES)('groupRowEditable behaviour (%s)', (editMode) => { + test('row grouping group rows only invoke groupRowEditable', async () => { + const groupRowEditableCalls: Parameters[] = []; + const groupRowEditable: GroupRowEditableCallback = (...args) => { + groupRowEditableCalls.push(args); + return true; + }; + const editableCalls: Parameters[] = []; + const editable: EditableCallback = (...args) => { + editableCalls.push(args); + return true; + }; + const committedValues = new Map(); + const valueSetterCalls: Parameters[] = []; + const valueSetter: ValueSetterCallback = (params) => { + valueSetterCalls.push([params]); + if (params.node?.id) { + committedValues.set(params.node.id, params.newValue); + } + if (params.data && params.colDef.field) { + (params.data as Record)[params.colDef.field] = params.newValue; + } else if (params.node?.groupData) { + params.node.groupData.group = params.newValue; + } + return true; + }; + + const gridOptions: GridOptions = { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable, + groupRowEditable, + valueSetter, + }, + { field: 'category', rowGroup: true, hide: true }, + ], + rowData: [ + { id: 'a-1', category: 'A', label: 'A1' }, + { id: 'a-2', category: 'A', label: 'A2' }, + ], + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = await gridsManager.createGridAndWait('row-group-groupRowEditable', gridOptions); + + const groupRowNode = api.getDisplayedRowAtIndex(0); + expect(groupRowNode).toBeDefined(); + expect(groupRowNode!.group).toBe(true); + expect(groupRowNode!.data).toBeUndefined(); + const originalGroupValue = getGroupColumnDisplayValue(groupRowNode!); + + groupRowEditableCalls.length = 0; + editableCalls.length = 0; + valueSetterCalls.length = 0; + const groupColumn = api.getDisplayedCenterColumns()[0]!; + expect(groupColumn.getColDef().groupRowEditable).toBe(groupRowEditable); + expect(groupColumn.isCellEditable(groupRowNode!)).toBe(true); + const groupColId = groupColumn.getColId(); + if (editMode === 'ui') { + await editCell(api, groupRowNode!, groupColId, 'Edited Group'); + } else { + groupRowNode!.setDataValue(groupColId, 'Edited Group', 'ui'); + await asyncSetTimeout(0); + } + expect(groupRowNode!.data).toBeUndefined(); + + const groupRowEditableCallsForGroup = callsForRowNode(groupRowEditableCalls, groupRowNode!.id); + const editableCallsForGroup = callsForRowNode(editableCalls, groupRowNode!.id); + if (editMode === 'ui') { + expect(groupRowEditableCallsForGroup.length).toBeGreaterThan(0); + } + expect(editableCallsForGroup.length).toBe(0); + + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(getGroupColumnDisplayValue(groupRowNode!)).toBe(originalGroupValue); + expect(groupRowNode!.data).toBeUndefined(); + expect(committedValues.get(groupRowNode!.id!)).toBe(originalGroupValue); + } + + const leafRowNode = api.getRowNode('a-1'); + expect(leafRowNode).toBeDefined(); + const originalLeafLabel = leafRowNode!.data!.label; + + groupRowEditableCalls.length = 0; + editableCalls.length = 0; + valueSetterCalls.length = 0; + if (editMode === 'ui') { + await editCell(api, leafRowNode!, groupColId, 'Edited Leaf'); + } else { + leafRowNode!.setDataValue(groupColId, 'Edited Leaf', 'ui'); + await asyncSetTimeout(0); + } + expect(leafRowNode!.data!.label).toBe('Edited Leaf'); + + const groupRowEditableCallsForLeaf = callsForRowNode(groupRowEditableCalls, leafRowNode!.id); + expect(groupRowEditableCallsForLeaf.length).toBe(0); + const editableCallsForLeaf = callsForRowNode(editableCalls, leafRowNode!.id); + if (editMode === 'ui') { + expect(editableCallsForLeaf.length).toBeGreaterThan(0); + } + const valueSetterCallsForLeaf = callsForRowNode(valueSetterCalls, leafRowNode!.id); + expect(valueSetterCallsForLeaf.length).toBeGreaterThan(0); + expect(committedValues.get('a-1')).toBe('Edited Leaf'); + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(leafRowNode!.data!.label).toBe(originalLeafLabel); + expect(committedValues.get('a-1')).toBe(originalLeafLabel); + } + }); + + test('group row edits run valueParser before valueSetter', async () => { + const parserCalls: ValueParserParams[] = []; + const parserOutputs: string[] = []; + const valueParser: ValueParserCallback = (params) => { + parserCalls.push(params); + const parsed = String(params.newValue ?? '') + .trim() + .toUpperCase(); + parserOutputs.push(parsed); + return parsed; + }; + const valueSetterValues: string[] = []; + const valueSetter: ValueSetterCallback = (params) => { + if (typeof params.newValue === 'string') { + valueSetterValues.push(params.newValue); + } + if (params.data && params.colDef.field) { + (params.data as Record)[params.colDef.field] = params.newValue; + } else if (params.node?.groupData) { + params.node.groupData.group = params.newValue as string; + } + return true; + }; + + const gridOptions: GridOptions = { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable: true, + groupRowEditable: true, + valueParser, + valueSetter, + }, + { field: 'category', rowGroup: true, hide: true }, + ], + rowData: [ + { id: 'a-1', category: 'A', label: 'A1' }, + { id: 'a-2', category: 'A', label: 'A2' }, + ], + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = await gridsManager.createGridAndWait('row-group-valueParser', gridOptions); + + const groupRowNode = api.getDisplayedRowAtIndex(0); + expect(groupRowNode).toBeDefined(); + const groupColumn = api.getDisplayedCenterColumns()[0]!; + const groupColId = groupColumn.getColId(); + const rawInput = ' parsed group '; + const expectedParsed = 'PARSED GROUP'; + + parserCalls.length = 0; + parserOutputs.length = 0; + valueSetterValues.length = 0; + + if (editMode === 'ui') { + await editCell(api, groupRowNode!, groupColId, rawInput); + expect(parserCalls.length).toBeGreaterThan(0); + expect(parserOutputs[parserOutputs.length - 1]).toBe(expectedParsed); + } else { + groupRowNode!.setDataValue(groupColId, rawInput, 'ui'); + await asyncSetTimeout(0); + expect(parserCalls.length).toBe(0); + } + }); + + test('tree data filler rows only invoke groupRowEditable', async () => { + const editableCalls: Parameters[] = []; + const editable: EditableCallback = (...args) => { + editableCalls.push(args); + return true; + }; + const groupRowEditableCalls: Parameters[] = []; + const groupRowEditable: GroupRowEditableCallback = (...args) => { + groupRowEditableCalls.push(args); + return true; + }; + const valueSetterCalls: Parameters[] = []; + const valueSetter: ValueSetterCallback = (params) => { + valueSetterCalls.push([params]); + if (!params.data && params.node?.groupData) { + params.node.groupData.group = params.newValue; + } + return true; + }; + + const api = await gridsManager.createGridAndWait('tree-data-filler-groupRowEditable', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable, + groupRowEditable, + valueSetter, + }, + ], + treeData: true, + rowData: [{ id: 'mars', path: ['Solar System', 'Mars'], label: 'Mars' }], + getDataPath: (data) => data.path, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + const fillerRowNode = api.getRowNode('row-group-0-Solar System'); + expect(fillerRowNode).toBeDefined(); + expect(fillerRowNode!.group).toBe(true); + expect(fillerRowNode!.data).toBeUndefined(); + const originalFillerValue = getGroupColumnDisplayValue(fillerRowNode!); + + const groupColumn = api.getDisplayedCenterColumns()[0]!; + expect(groupColumn.getColDef().groupRowEditable).toBe(groupRowEditable); + + groupRowEditableCalls.length = 0; + editableCalls.length = 0; + valueSetterCalls.length = 0; + if (editMode === 'ui') { + await editCell(api, fillerRowNode!, 'group', 'Edited Filler'); + } else { + fillerRowNode!.setDataValue('group', 'Edited Filler', 'ui'); + await asyncSetTimeout(0); + } + expect(fillerRowNode!.data).toBeUndefined(); + + const groupRowEditableCallsForFiller = callsForRowNode(groupRowEditableCalls, fillerRowNode!.id); + const editableCallsForFiller = callsForRowNode(editableCalls, fillerRowNode!.id); + if (editMode === 'ui') { + expect(groupRowEditableCallsForFiller.length).toBeGreaterThan(0); + } + expect(editableCallsForFiller.length).toBe(0); + + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(getGroupColumnDisplayValue(fillerRowNode!)).toBe(originalFillerValue); + expect(fillerRowNode!.data).toBeUndefined(); + } + }); + + test('tree data group rows with data prefer groupRowEditable when defined', async () => { + const editableCalls: Parameters[] = []; + const editable: EditableCallback = (...args) => { + editableCalls.push(args); + return true; + }; + const groupRowEditableCalls: Parameters[] = []; + const groupRowEditable: GroupRowEditableCallback = (...args) => { + groupRowEditableCalls.push(args); + return true; + }; + const rowData = [ + { id: 'earth', path: ['Earth'], label: 'Earth label' }, + { id: 'moon', path: ['Earth', 'Moon'], label: 'Moon label' }, + ]; + const originalEarthLabel = rowData[0].label; + const valueSetterCalls: Parameters[] = []; + const valueSetter: ValueSetterCallback = (params) => { + valueSetterCalls.push([params]); + if (params.data) { + (params.data as { label?: string }).label = params.newValue; + } + return true; + }; + + const api = await gridsManager.createGridAndWait('tree-data-groupRowEditable', { + defaultColDef: { + cellEditor: 'agTextCellEditor', + }, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable, + groupRowEditable, + valueSetter, + }, + ], + treeData: true, + rowData, + getDataPath: (data) => data.path, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + const earthRowNode = api.getRowNode('earth'); + expect(earthRowNode).toBeDefined(); + + groupRowEditableCalls.length = 0; + editableCalls.length = 0; + valueSetterCalls.length = 0; + if (editMode === 'ui') { + await editCell(api, earthRowNode!, 'group', 'Edited Earth'); + } else { + earthRowNode!.setDataValue('group', 'Edited Earth', 'ui'); + await asyncSetTimeout(0); + } + + const groupRowEditableCallsForEarth = callsForRowNode(groupRowEditableCalls, earthRowNode!.id); + const editableCallsForEarth = callsForRowNode(editableCalls, earthRowNode!.id); + if (editMode === 'ui') { + expect(groupRowEditableCallsForEarth.length).toBeGreaterThan(0); + } + expect(editableCallsForEarth.length).toBe(0); + const valueSetterCallsForEarth = callsForRowNode(valueSetterCalls, earthRowNode!.id); + expect(valueSetterCallsForEarth.length).toBeGreaterThan(0); + expect(rowData[0].label).toBe('Edited Earth'); + + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(rowData[0].label).toBe(originalEarthLabel); + } + }); + + test('tree data group rows with data fall back to editable when groupRowEditable missing', async () => { + const editableCalls: Parameters[] = []; + const editable: EditableCallback = (...args) => { + editableCalls.push(args); + return true; + }; + const rowData = [ + { id: 'earth', path: ['Earth'], label: 'Earth label' }, + { id: 'moon', path: ['Earth', 'Moon'], label: 'Moon label' }, + ]; + const originalEarthLabel = rowData[0].label; + const valueSetterCalls: Parameters[] = []; + const valueSetter: ValueSetterCallback = (params) => { + valueSetterCalls.push([params]); + if (params.data) { + (params.data as { label?: string }).label = params.newValue; + } + return true; + }; + + const api = await gridsManager.createGridAndWait('tree-data-groupRowEditable-fallback', { + enableGroupEdit: true, + undoRedoCellEditing: true, + groupDisplayType: 'custom', + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + field: 'label', + cellRenderer: 'agGroupCellRenderer', + cellRendererParams: { + suppressCount: true, + }, + editable, + valueSetter, + }, + ], + treeData: true, + rowData, + getDataPath: (data) => data.path, + groupDefaultExpanded: -1, + getRowId: (params) => params.data?.id, + }); + + const earthRowNode = api.getRowNode('earth'); + expect(earthRowNode).toBeDefined(); + + editableCalls.length = 0; + valueSetterCalls.length = 0; + if (editMode === 'ui') { + await editCell(api, earthRowNode!, 'group', 'Edited Earth'); + } else { + earthRowNode!.setDataValue('group', 'Edited Earth', 'ui'); + await asyncSetTimeout(0); + } + + const editableCallsForEarth = callsForRowNode(editableCalls, earthRowNode!.id); + if (editMode === 'ui') { + expect(editableCallsForEarth.length).toBeGreaterThan(0); + } + const valueSetterCallsForEarth = callsForRowNode(valueSetterCalls, earthRowNode!.id); + expect(valueSetterCallsForEarth.length).toBeGreaterThan(0); + expect(rowData[0].label).toBe('Edited Earth'); + + if (editMode === 'ui') { + api.undoCellEditing(); + await asyncSetTimeout(0); + expect(rowData[0].label).toBe(originalEarthLabel); + } + }); + + test('groupRowValueSetter fires even when groupRowEditable is false', async () => { + let invocationCount = 0; + const valueSetter: ValueSetterCallback = (params) => { + if (params.node?.group) { + const groupData = params.node.groupData ?? {}; + groupData.group = params.newValue; + params.node.groupData = groupData; + } + return true; + }; + const groupRowValueSetter: GroupRowValueSetterCallback = () => { + invocationCount += 1; + }; + + const api = await gridsManager.createGridAndWait('group-row-set-value-without-editable', { + columnDefs: [ + { + colId: 'group', + headerName: 'Group', + cellRenderer: 'agGroupCellRenderer', + editable: false, + groupRowEditable: false, + valueSetter, + groupRowValueSetter, + }, + { field: 'category', rowGroup: true, hide: true }, + ], + rowData: [ + { id: 'a-1', category: 'A', label: 'A1' }, + { id: 'a-2', category: 'A', label: 'A2' }, + ], + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }); + + const groupRowNode = api.getDisplayedRowAtIndex(0); + expect(groupRowNode?.group).toBe(true); + const targetColumn = api.getColumns()?.find((col) => col.getColId() === 'group'); + expect(targetColumn).toBeDefined(); + + groupRowNode!.setDataValue(targetColumn!, 'Edited Group', 'ui'); + await asyncSetTimeout(0); + + expect(invocationCount).toBe(1); + }); +}); diff --git a/testing/behavioural/src/filters/aggregate-filters.test.ts b/testing/behavioural/src/filters/aggregate-filters.test.ts index 5112321cb61..f6cc4214325 100644 --- a/testing/behavioural/src/filters/aggregate-filters.test.ts +++ b/testing/behavioural/src/filters/aggregate-filters.test.ts @@ -1,11 +1,11 @@ import { ClientSideRowModelModule, NumberFilterModule, TextFilterModule, setupAgTestIds } from 'ag-grid-community'; -import { RowGroupingModule } from 'ag-grid-enterprise'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; import { GridRows, TestGridsManager } from '../test-utils'; describe('Aggregate Filters', () => { const gridsManager = new TestGridsManager({ - modules: [ClientSideRowModelModule, TextFilterModule, NumberFilterModule, RowGroupingModule], + modules: [ClientSideRowModelModule, TextFilterModule, NumberFilterModule, RowGroupingModule, PivotModule], }); const rowData = [