Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 },
];
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="myGrid" class="ag-theme-quartz" style="height: 100%; width: 100%"></div>
Original file line number Diff line number Diff line change
@@ -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<SalesRecord> = ({ 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<SalesRecord> = {
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<HTMLElement>('#myGrid');
if (!gridDiv) {
return;
}
createGrid(gridDiv, gridOptions);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
4 changes: 2 additions & 2 deletions packages/ag-grid-community/src/columns/dataTypeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,8 @@ export class DataTypeService extends BeanStub implements NamedBean {
cellEditor: 'agCheckboxCellEditor',
cellRenderer: 'agCheckboxCellRenderer',
getFindText: () => null,
suppressKeyboardEvent: (params: SuppressKeyboardEventParams<any, boolean>) =>
!!params.colDef.editable && params.event.key === KeyCode.SPACE,
suppressKeyboardEvent: ({ node, event, column }: SuppressKeyboardEventParams<any, boolean>) =>
event.key === KeyCode.SPACE && column.isCellEditable(node),
};
},
date({ formatValue }) {
Expand Down
7 changes: 4 additions & 3 deletions packages/ag-grid-community/src/edit/editService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,10 @@ export class EditService extends BeanStub implements NamedBean, IEditService {
}

public isCellEditable(position: Required<EditPosition>, 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.
Expand All @@ -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();
Expand Down
56 changes: 40 additions & 16 deletions packages/ag-grid-community/src/edit/strategy/strategyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,31 +66,55 @@ function deriveClickCount(gos: GridOptionsService, colDef?: ColDef): number {
return 2;
}

export function isCellEditable(
beans: BeanCollection,
{ rowNode, column }: Required<EditPosition>,
_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<EditPosition>): boolean {
return beans.editModelSvc?.hasEdits(editPosition, { withOpenEditor: true }) ?? false;
}

export function isCellEditable(beans: BeanCollection, editPosition: Required<EditPosition>): 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(
beans: BeanCollection,
position: Required<EditPosition>,
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;
}
2 changes: 1 addition & 1 deletion packages/ag-grid-community/src/edit/utils/editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
34 changes: 34 additions & 0 deletions packages/ag-grid-community/src/entities/colDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,21 @@ export interface ColDef<TData = any, TValue = any> extends AbstractColDef<TData,

/**
* Set to `true` if this column is editable, otherwise `false`. Can also be a function to have different rows editable.
* When grouping, see `groupRowEditable` instead for group rows.
* @default false
*/
editable?: boolean | EditableCallback<TData, TValue>;
/**
* 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<TData, TValue>;
/**
* 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<TData, TValue>;
/** Function or expression. Sets the value into your data for saving. Return `true` if the data changed. */
valueSetter?: string | ValueSetterFunc<TData, TValue>;
/** Function or expression. Parses the value for saving. */
Expand Down Expand Up @@ -955,6 +967,28 @@ export interface EditableCallbackParams<TData = any, TValue = any, TContext = an
export type EditableCallback<TData = any, TValue = any, TContext = any> = (
params: EditableCallbackParams<TData, TValue, TContext>
) => boolean;
export interface GroupRowEditableCallbackParams<TData = any, TValue = any, TContext = any>
extends ColumnFunctionCallbackParams<TData, TValue, TContext> {}
export type GroupRowEditableCallback<TData = any, TValue = any, TContext = any> = (
params: GroupRowEditableCallbackParams<TData, TValue, TContext>
) => boolean;
export interface GroupRowValueSetterParams<TData = any, TValue = any, TContext = any>
extends Omit<
ChangedValueParams<TData, TValue | null | undefined, TValue | null | undefined, TContext>,
'node' | 'data'
> {
/** Group row that triggered the callback. */
node: IRowNode<TData>;
/** 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<TData = any, TValue = any, TContext = any> = (
params: GroupRowValueSetterParams<TData, TValue, TContext>
) => void | boolean | undefined;
export interface SuppressPasteCallbackParams<TData = any, TValue = any, TContext = any>
extends ColumnFunctionCallbackParams<TData, TValue, TContext> {}
export type SuppressPasteCallback<TData = any, TValue = any, TContext = any> = (
Expand Down
4 changes: 4 additions & 0 deletions packages/ag-grid-community/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,10 @@ export {
ValueParserParams,
ValueSetterFunc,
ValueSetterParams,
GroupRowEditableCallback,
GroupRowEditableCallbackParams,
GroupRowValueSetterParams,
GroupRowValueSetterFunc,
} from './entities/colDef';
export {
BaseCellDataType,
Expand Down
Loading
Loading