Skip to content

Grid Cell merging #16024

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 55 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
7562cc5
chore(*): Cell merge POC.
Jul 2, 2025
f42bb3c
chore(*): Minor tweaks to suggestion.
Jul 2, 2025
870e7f6
chore(*): Implement with rows that are retained outside the virt.frame.
Jul 3, 2025
bb4e39f
chore(*): Fix border styles.
Jul 7, 2025
e160c65
chore(*): Add handling for variable row height.
Jul 7, 2025
4667bb0
chore(*): Add handling if different rows have different sizes.
Jul 7, 2025
febbd43
chore(*): Extract hardcoded styles in class.
Jul 7, 2025
4c5d922
chore(*): Adjust API and members.
Jul 7, 2025
a42b192
chore(*): Add mergeStrategy for grid and merging comparer for column.
Jul 8, 2025
1361952
chore(*): Adjust pipe triggers.
Jul 9, 2025
407357f
chore(*): Re-use cell templates.
Jul 9, 2025
f23dd6c
chore(*): Fix row indexes in merged row area.
Jul 9, 2025
f6ffa42
chore(*): Adjust some styles for row selection.
Jul 9, 2025
4d1b981
chore(*): Merged cells navigation.
Jul 10, 2025
aca57ee
chore(*): Adjust for scenarios after horizontal nav into merged cell.
Jul 10, 2025
e168c3a
chore(*): Add integration with pinning.
Jul 10, 2025
02b866a
chore(*): Merge cells in pinned col.
Jul 11, 2025
d515e97
chore(*): Adjust check for non-mergeable record types.
Jul 11, 2025
95e5b40
chore(*): Break merge sequence on row activation. Discard custom merg…
Jul 11, 2025
759b465
chore(*): Merge in pinned row area.
Jul 11, 2025
9329079
chore(*): Implement selection per spec.
Jul 14, 2025
19c2efe
chore(*): Add hover styles.
Jul 14, 2025
52860e8
chore(*): Fix duplicate host listeners.
Jul 14, 2025
16d4ac6
chore(*): Merge ghost(pinned) records with same value in main area.
Jul 14, 2025
f81bd27
chore(*): Fix check for merged data.
Jul 15, 2025
2ba947e
chore(*): Refactor to calculate mergedData rows only on chunk change.
Jul 15, 2025
95d0186
chore(*): Cache merge data index and re-use in template.
Jul 15, 2025
2a65bc2
chore(*): Cell merge in hierarchical grid - initial commit.
Jul 15, 2025
5c4587f
chore(*): Implement for tree grid. Add 2 strategies.
Jul 16, 2025
7ac17e4
chore(*): Export strategies in package.
Jul 16, 2025
401370e
chore(*): Fix chip displaying when in merged cell.
Jul 16, 2025
5e1b702
chore(*): Minor tweaks. Console warn on invalid setup.
Jul 16, 2025
58e6bfa
chore(*): Fix lint errors.
Jul 17, 2025
5e81bf9
chore(*): Fix theming lint.
Jul 17, 2025
b2b61a7
chore(*): Fix build and imports.
Jul 17, 2025
9a7a2d2
chore(*): Add null check.
Jul 17, 2025
ce0c854
chore(*): Null check for activeRowIndex.
Jul 17, 2025
a6fc511
chore(*): Generate elements config.
Jul 17, 2025
d025473
chore(*): Break up merge groups on cell selection.
Jul 17, 2025
190f29d
chore(*): Update active indexes on events. Cache result to limit pipe…
Jul 18, 2025
50e7d85
chore(*): When searching, mark merged cells as a single result.
Jul 18, 2025
1e560e8
chore(*): Refresh search if needed only.
Jul 18, 2025
7db787a
chore(*): Fix scrollTo when scrolling to a merged cell that has large…
Jul 21, 2025
86bc021
chore(*): Add basic merging tests.
Jul 21, 2025
91c457b
chore(*): Add some UI tests for merging.
Jul 21, 2025
a5c5566
chore(*): Add some integration tests.
Jul 22, 2025
46ea7ae
chore(*): Add more integration tests.
Jul 23, 2025
f862469
chore(*): Add more integration tests.
Jul 23, 2025
fc46739
chore(*): Add Hgrid and TreeGrid integration tests.
Jul 24, 2025
ea9baa6
chore(*): Fix lint i tests.
Jul 24, 2025
1f4c554
chore(*): Update Changelog.
Jul 24, 2025
94eb0a8
Merge branch 'master' into mkirova/cell-merge-POC-2
Jul 24, 2025
cc47820
chore(*): Fix unrelated respy issue in combo tests.
Jul 24, 2025
35a1bde
chore(*): Fix hardcoded value in unrelated test.
Jul 24, 2025
a7970d1
chore(*): Improve samples a bit.
Jul 25, 2025
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

All notable changes for each version of this project will be documented in this file.

## 20.1.0

### New Features

- `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid`
- Introduced a new cell merging feature that allows you to configure and merge cells in a column based on same data or other custom condition, into a single cell.

It can be enabled on the individual columns:

```html
<igx-column field="field" [merge]="true"></igx-column>
```
The merging can be configured on the grid level to apply either:
- `onSort` - only when the column is sorted.
- `always` - always, regardless of data operations.

```html
<igx-grid [cellMergeMode]="'always'">
</igx-grid>
```

The default `cellMergeMode` is `onSort`.

The functionality can be modified by setting a custom `mergeStrategy` on the grid, in case some other merge conditions or logic is needed for a custom scenario.

It's possible also to set a `mergeComparer` on the individual columns, in case some custom handling is needed for a particular data field.


## 20.0.6
### General
- `IgxSimpleCombo`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export var registerConfig = [
],
numericProps: ["rowEnd", "colEnd", "rowStart", "colStart"],
boolProps: [
"merge",
"sortable",
"selectable",
"groupable",
Expand Down Expand Up @@ -158,6 +159,7 @@ export var registerConfig = [
"expanded",
"searchable",
"hidden",
"merge",
"sortable",
"groupable",
"editable",
Expand Down Expand Up @@ -213,6 +215,7 @@ export var registerConfig = [
"collapsible",
"expanded",
"searchable",
"merge",
"sortable",
"groupable",
"editable",
Expand Down
3 changes: 2 additions & 1 deletion projects/igniteui-angular-elements/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { IgxPivotDateDimension } from 'projects/igniteui-angular/src/lib/grids/p
import { PivotDimensionType } from 'projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.interface';
import { IgxDateSummaryOperand, IgxNumberSummaryOperand, IgxSummaryOperand, IgxTimeSummaryOperand } from 'projects/igniteui-angular/src/lib/grids/summaries/grid-summary';
import { HorizontalAlignment, VerticalAlignment } from 'projects/igniteui-angular/src/lib/services/overlay/utilities';

import { ByLevelTreeGridMergeStrategy } from 'projects/igniteui-angular/src/lib/data-operations/merge-strategy';

/** Export Public API, TODO: reorganize, Generate all w/ renames? */
export {
Expand All @@ -35,6 +35,7 @@ export {

NoopSortingStrategy as IgcNoopSortingStrategy,
NoopFilteringStrategy as IgcNoopFilteringStrategy,
ByLevelTreeGridMergeStrategy as IgcByLevelTreeGridMergeStrategy,

// Pivot API
IgxPivotDateDimension as IgcPivotDateDimension,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@
@extend %igx-grid__td--edited !optional;
}

@include e(td, $m: merged) {
@extend %igx-grid__td--merged !optional;
}


@include e(td, $m: merged-selected) {
@extend %igx-grid__td--merged-selected !optional;
}

@include e(td, $m: merged-hovered) {
@extend %igx-grid__td--merged-hovered !optional;
}

@include e(td, $m: editing) {
@extend %igx-grid__td--editing !optional;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,7 @@

%grid-row--mrl {
%igx-grid__hierarchical-expander--header,
%igx-grid__hierarchical-expander,
%igx-grid__header-indentation,
%igx-grid__row-indentation,
%grid__cbx-selection {
Expand Down Expand Up @@ -1310,6 +1311,7 @@
}

%grid__cbx-selection,
%igx-grid__hierarchical-expander,
%igx-grid__row-indentation,
%igx-grid__drag-indicator {
border-bottom: rem(1px) solid var-get($theme, 'row-border-color');
Expand Down Expand Up @@ -1847,6 +1849,27 @@
color: var-get($theme, 'row-selected-text-color');
background: var-get($theme, 'row-selected-background');

&%grid-row--mrl {
%grid-mrl-block {
%igx-grid__td--merged {
color: var-get($theme, 'row-selected-text-color');
background: var-get($theme, 'row-selected-background');

&:hover {
background: var-get($theme, 'row-selected-hover-background');
color: var-get($theme, 'row-selected-hover-text-color');
}

&%igx-grid__td--merged-hovered {
background: var-get($theme, 'row-selected-hover-background');
color: var-get($theme, 'row-selected-hover-text-color');
}
}
}
}



%grid-cell--selected,
%grid-cell--pinned-selected {
color: var-get($theme, 'cell-selected-within-text-color');
Expand Down Expand Up @@ -1894,6 +1917,31 @@
}
}

%igx-grid__td--merged {
z-index: 1;
grid-row: 1 / -1;
}

%igx-grid__td--merged-selected {
color: var-get($theme, 'row-selected-text-color');
background: var-get($theme, 'row-selected-background');

&%igx-grid__td--merged-hovered {
background: var-get($theme, 'row-selected-hover-background');
color: var-get($theme, 'row-selected-hover-text-color');
}
}

%igx-grid__td--merged-hovered {
background: var-get($theme, 'row-hover-background');
color: var-get($theme, 'row-hover-text-color');

&%igx-grid__td--merged-selected {
background: var-get($theme, 'row-selected-hover-background');
color: var-get($theme, 'row-selected-hover-text-color');
}
}

%igx-grid__tr--deleted {
%grid-cell-text {
font-style: italic;
Expand Down
12 changes: 11 additions & 1 deletion projects/igniteui-angular/src/lib/data-operations/data-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { IGroupingState } from './groupby-state.interface';
import { mergeObjects } from '../core/utils';
import { Transaction, TransactionType, HierarchicalTransaction } from '../services/transaction/transaction';
import { getHierarchy, isHierarchyMatch } from './operations';
import { GridType } from '../grids/common/grid.interface';
import { ColumnType, GridType } from '../grids/common/grid.interface';
import { ITreeGridRecord } from '../grids/tree-grid/tree-grid.interfaces';
import { ISortingExpression } from './sorting-strategy';
import {
Expand All @@ -20,6 +20,7 @@ import {
} from '../grids/common/strategy';
import { DefaultDataCloneStrategy, IDataCloneStrategy } from '../data-operations/data-clone-strategy';
import { IGroupingExpression } from './grouping-expression.interface';
import { DefaultMergeStrategy, IGridMergeStrategy } from './merge-strategy';

/**
* @hidden
Expand Down Expand Up @@ -90,6 +91,15 @@ export class DataUtil {
return grouping.groupBy(data, state, grid, groupsRecords, fullResult);
}

public static merge<T>(data: T[], columns: ColumnType[], strategy: IGridMergeStrategy = new DefaultMergeStrategy(), activeRowIndexes = [], grid: GridType = null,
): any[] {
let result = [];
for (const col of columns) {
strategy.merge(data, col.field, col.mergingComparer, result, activeRowIndexes, grid);
}
return result;
}

public static page<T>(data: T[], state: IPagingState, dataLength?: number): T[] {
if (!state) {
return data;
Expand Down
128 changes: 128 additions & 0 deletions projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { GridType } from '../grids/common/grid.interface';




export interface IMergeByResult {
rowSpan: number;
root?: any;
prev?: any;
}

export interface IGridMergeStrategy {
/* blazorSuppress */
merge: (
data: any[],
field: string,
comparer: (prevRecord: any, currentRecord: any, field: string) => boolean,
result: any[],
activeRowIndexes : number[],
grid?: GridType
) => any[];
comparer: (prevRecord: any, record: any, field: string) => boolean;
}

export class DefaultMergeStrategy implements IGridMergeStrategy {
protected static _instance: DefaultMergeStrategy = null;

public static instance(): DefaultMergeStrategy {
return this._instance || (this._instance = new this());
}

/* blazorSuppress */
public merge(
data: any[],
field: string,
comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer,
result: any[],
activeRowIndexes : number[],
grid?: GridType
) {
let prev = null;
let index = 0;
for (const rec of data) {

const recData = result[index];
// if this is active row or some special record type - add and skip merging
if (activeRowIndexes.indexOf(index) != -1 || (grid && grid.isDetailRecord(rec) || grid.isGroupByRecord(rec) || grid.isChildGridRecord(rec))) {
if(!recData) {
result.push(rec);
}
prev = null;
index++;
continue;
}
let recToUpdateData = recData ?? { recordRef: grid.isGhostRecord(rec) ? rec.recordRef : rec, cellMergeMeta: new Map<string, IMergeByResult>(), ghostRecord: rec.ghostRecord };
recToUpdateData.cellMergeMeta.set(field, { rowSpan: 1 });
if (prev && comparer(prev.recordRef, recToUpdateData.recordRef, field) && prev.ghostRecord === recToUpdateData.ghostRecord) {
const root = prev.cellMergeMeta.get(field)?.root ?? prev;
root.cellMergeMeta.get(field).rowSpan += 1;
recToUpdateData.cellMergeMeta.get(field).root = root;
}
prev = recToUpdateData;
if (!recData) {
result.push(recToUpdateData);
}
index++;
}
return result;
}

/* blazorSuppress */
public comparer(prevRecord: any, record: any, field: string): boolean {
const a = prevRecord[field];
const b = record[field];
const an = (a === null || a === undefined);
const bn = (b === null || b === undefined);
if (an) {
if (bn) {
return true;
}
return false;
} else if (bn) {
return false;
}
return a === b;
}
}


export class DefaultTreeGridMergeStrategy extends DefaultMergeStrategy {
/* blazorSuppress */
public override comparer(prevRecord: any, record: any, field: string): boolean {
const a = prevRecord.data[field];
const b = record.data[field];
const an = (a === null || a === undefined);
const bn = (b === null || b === undefined);
if (an) {
if (bn) {
return true;
}
return false;
} else if (bn) {
return false;
}
return a === b;
}
}

export class ByLevelTreeGridMergeStrategy extends DefaultMergeStrategy {
/* blazorSuppress */
public override comparer(prevRecord: any, record: any, field: string): boolean {
const a = prevRecord.data[field];
const b = record.data[field];
const levelA = prevRecord.level;
const levelB = record.level;
const an = (a === null || a === undefined);
const bn = (b === null || b === undefined);
if (an) {
if (bn) {
return true;
}
return false;
} else if (bn) {
return false;
}
return a === b && levelA === levelB;
}
}
Loading
Loading