Skip to content

Commit d6c950b

Browse files
authored
Merge pull request #6119 from IgniteUI/mkirova/grid-master-detail
Grid Master Detail
2 parents a05bf6a + f958a91 commit d6c950b

27 files changed

+2040
-145
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ All notable changes for each version of this project will be documented in this
2424

2525
### New Features
2626
- `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid`:
27+
- Master-Detail visualization added for `igxGrid`. Users may now define templates that show additional context for rows when expanded. For more information, please take a look at the [official documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/master_detail.html).
2728
- `sortStrategy` input is added, which can be used to set a global sorting strategy for the entire grid.
2829
(**NOTE**: The grid's `sortStrategy` is of different type compared to the column's `sortStrategy`.)
2930
- `NoopSortingStrategy` is added, which can be used to disable the default sorting of the grid by assigning its instance to the grid's `sortStrategy` input. (Useful for remote sorting.)

projects/igniteui-angular/src/lib/grids/cell.component.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ColumnType } from './common/column.interface';
2828
import { RowType } from './common/row.interface';
2929
import { GridSelectionMode } from './common/enums';
3030
import { GridType } from './common/grid.interface';
31+
import { IgxGridComponent } from './grid';
3132

3233
/**
3334
* Providing reference to `IgxGridCellComponent`:
@@ -850,6 +851,26 @@ export class IgxGridCellComponent implements OnInit, OnChanges, OnDestroy {
850851
} else if (expand) {
851852
(this.gridAPI as any).trigger_row_expansion_toggle(this.row.treeRow, !this.row.expanded, event, this.visibleColumnIndex);
852853
}
854+
} else if ((this.grid as IgxGridComponent).hasDetails && this.isToggleKey(key)) {
855+
const collapse = (this.row as any).expanded && ROW_COLLAPSE_KEYS.has(key);
856+
const expand = !(this.row as any).expanded && ROW_EXPAND_KEYS.has(key);
857+
const expandedStates = this.grid.expansionStates;
858+
if (expand) {
859+
expandedStates.set(this.row.rowID, true);
860+
} else if (collapse) {
861+
expandedStates.set(this.row.rowID, false);
862+
}
863+
this.grid.expansionStates = expandedStates;
864+
this.grid.notifyChanges();
865+
const isVirtualized = !this.grid.verticalScrollContainer.dc.instance.notVirtual;
866+
// persist focused cell
867+
const el = this.grid.selectionService.activeElement;
868+
if (isVirtualized && el) {
869+
const cell = this.grid.gridAPI.get_cell_by_visible_index(el.row, el.column);
870+
if (cell) {
871+
cell.nativeElement.focus();
872+
}
873+
}
853874
}
854875
}
855876

projects/igniteui-angular/src/lib/grids/common/grid.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface GridType extends IGridDataBindable {
2323
allowFiltering: boolean;
2424
rowDraggable: boolean;
2525
primaryKey: any;
26+
id: string;
2627

2728
filterMode: FilterMode;
2829

@@ -51,6 +52,7 @@ export interface GridType extends IGridDataBindable {
5152

5253
firstEditableColumnIndex: number;
5354
lastEditableColumnIndex: number;
55+
hasDetails: boolean;
5456

5557
sortingExpressions: ISortingExpression[];
5658
sortingExpressionsChange: EventEmitter<ISortingExpression[]>;
@@ -62,4 +64,5 @@ export interface GridType extends IGridDataBindable {
6264
sort(expression: ISortingExpression | Array<ISortingExpression>): void;
6365
clearSort(name?: string): void;
6466
isColumnGrouped(fieldName: string): boolean;
67+
isDetailRecord(rec: any): boolean;
6568
}

projects/igniteui-angular/src/lib/grids/grid-base.directive.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2866,6 +2866,22 @@ export class IgxGridBaseDirective extends DisplayDensityBase implements
28662866
this.hideOverlays();
28672867
}
28682868

2869+
/**
2870+
* @hidden
2871+
* @internal
2872+
*/
2873+
public isDetailRecord(rec) {
2874+
return false;
2875+
}
2876+
2877+
/**
2878+
* @hidden
2879+
* @internal
2880+
*/
2881+
public get hasDetails() {
2882+
return false;
2883+
}
2884+
28692885
/**
28702886
* @hidden
28712887
* @internal

projects/igniteui-angular/src/lib/grids/grid-mrl-navigation.service.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
184184
}
185185

186186
private focusCellUpFromLayout(rowElement: HTMLElement, selectedNode: ISelectionNode) {
187-
const isGroupRow = rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row';
187+
const isNonDataRow = rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row' || this._isDetailRecordAt(selectedNode.row);
188188
const currentRowStart = selectedNode.layout ? selectedNode.layout.rowStart : 1;
189189
const currentColStart = this.applyNavigationCell(selectedNode.layout ? selectedNode.layout.colStart : 1,
190190
currentRowStart,
@@ -197,7 +197,7 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
197197
(c.rowEnd === currentRowStart || c.rowStart + c.gridRowSpan === currentRowStart) &&
198198
c.colStart <= currentColStart &&
199199
(currentColStart < c.colEnd || currentColStart < c.colStart + c.gridColumnSpan));
200-
if (isGroupRow || !upperElementColumn) {
200+
if (isNonDataRow || !upperElementColumn) {
201201
// no prev row in current row layout, go to next row last rowstart
202202
const layoutRowEnd = this.grid.multiRowLayoutRowSize + 1;
203203
upperElementColumn = columnLayout.children.find(c =>
@@ -218,6 +218,9 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
218218
this._focusCell(upperElementColumn.cells.find((c) => c.rowIndex === prevRow.index).nativeElement);
219219
} else if (prevRow) {
220220
prevRow.nativeElement.focus({ preventScroll: true });
221+
} else {
222+
const prevElem = this.getRowByIndex(rowIndex, '') as any;
223+
prevElem.focus({ preventScroll: true });
221224
}
222225
};
223226
if (this.shouldPerformVerticalScroll(rowIndex, upperElementColumn.visibleIndex)) {
@@ -229,7 +232,7 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
229232
}
230233

231234
private focusCellDownFromLayout(rowElement: HTMLElement, selectedNode: ISelectionNode) {
232-
const isGroupRow = rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row';
235+
const isNonDataRow = rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row' || this._isDetailRecordAt(selectedNode.row);
233236
const parentIndex = selectedNode.column;
234237
const columnLayout = this.grid.columns.find( x => x.columnLayout && x.visibleIndex === parentIndex);
235238
const currentRowEnd = selectedNode.layout ? selectedNode.layout.rowEnd || selectedNode.layout.rowStart + 1 : 2;
@@ -241,7 +244,7 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
241244
let nextElementColumn = columnLayout.children.find(c => c.rowStart === currentRowEnd &&
242245
c.colStart <= currentColStart &&
243246
(currentColStart < c.colEnd || currentColStart < c.colStart + c.gridColumnSpan));
244-
if (isGroupRow || !nextElementColumn) {
247+
if (isNonDataRow || !nextElementColumn) {
245248
// no next row in current row layout, go to next row first rowstart
246249
nextElementColumn = columnLayout.children.find(c => c.rowStart === 1 &&
247250
c.colStart <= currentColStart &&
@@ -260,6 +263,9 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
260263
this._focusCell(nextElementColumn.cells.find((c) => c.rowIndex === nextRow.index).nativeElement);
261264
} else if (nextRow) {
262265
nextRow.nativeElement.focus({ preventScroll: true });
266+
} else {
267+
const nextElem = this.getRowByIndex(rowIndex, '') as any;
268+
nextElem.focus({ preventScroll: true });
263269
}
264270
};
265271
if (this.shouldPerformVerticalScroll(rowIndex, nextElementColumn.visibleIndex)) {
@@ -447,7 +453,7 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
447453
}
448454

449455
public shouldPerformVerticalScroll(rowIndex: number, visibleColumnIndex: number): boolean {
450-
if (this._isGroupRecordAt(rowIndex)) {
456+
if (this._isGroupRecordAt(rowIndex) || this._isDetailRecordAt(rowIndex)) {
451457
return super.shouldPerformVerticalScroll(rowIndex, visibleColumnIndex);
452458
}
453459
if (!super.shouldPerformVerticalScroll(rowIndex, visibleColumnIndex)) {return false; }
@@ -472,9 +478,13 @@ export class IgxGridMRLNavigationService extends IgxGridNavigationService {
472478
const record = this.grid.dataView[rowIndex];
473479
return record.records && record.records.length;
474480
}
481+
private _isDetailRecordAt(rowIndex: number) {
482+
const record = this.grid.dataView[rowIndex];
483+
return this.grid.isDetailRecord(record);
484+
}
475485

476486
public performVerticalScrollToCell(rowIndex: number, visibleColumnIndex: number, cb?: () => void) {
477-
if (this._isGroupRecordAt(rowIndex)) {
487+
if (this._isGroupRecordAt(rowIndex) || this._isDetailRecordAt(rowIndex)) {
478488
return super.performVerticalScrollToCell(rowIndex, visibleColumnIndex, cb);
479489
}
480490
const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0;

projects/igniteui-angular/src/lib/grids/grid-navigation.service.ts

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export class IgxGridNavigationService {
273273
const cells = this.grid.nativeElement.querySelectorAll(
274274
`${cellSelector}[data-visibleIndex="${visibleColumnIndex}"]`);
275275
if (cells.length > 0) {
276-
(cells[cells.length - 1] as HTMLElement).focus();
276+
(cells[cells.length - 1] as HTMLElement).focus({preventScroll: true});
277277
}
278278
});
279279
}
@@ -294,13 +294,7 @@ export class IgxGridNavigationService {
294294
.pipe(first())
295295
.subscribe(() => {
296296
const tag = rowElement.tagName.toLowerCase();
297-
const rowSelector = this.getRowSelector();
298-
if (tag === rowSelector || tag === 'igx-grid-summary-row') {
299-
rowElement = this.getRowByIndex(currentRowIndex, tag);
300-
} else {
301-
rowElement = this.grid.nativeElement.querySelector(
302-
`igx-grid-groupby-row[data-rowindex="${currentRowIndex}"]`);
303-
}
297+
rowElement = this.getRowByIndex(currentRowIndex, tag);
304298
this.focusPreviousElement(rowElement, visibleColumnIndex);
305299
});
306300
} else {
@@ -341,7 +335,7 @@ export class IgxGridNavigationService {
341335
}
342336

343337
protected focusElem(rowElement, visibleColumnIndex) {
344-
if (rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row') {
338+
if (rowElement.tagName.toLowerCase() === 'igx-grid-groupby-row' || rowElement.className === 'igx-grid__tr-container') {
345339
rowElement.focus();
346340
} else {
347341
const isSummaryRow = rowElement.tagName.toLowerCase() === 'igx-grid-summary-row';
@@ -433,17 +427,24 @@ export class IgxGridNavigationService {
433427
const rowIndex = selectedNode.row;
434428
const visibleColumnIndex = selectedNode.column;
435429
const isSummaryRow = selectedNode.isSummaryRow;
430+
const nextIsDetailRow = rowIndex + 1 <= this.grid.dataView.length - 1 ?
431+
this.grid.isDetailRecord(this.grid.dataView[rowIndex + 1]) : false;
432+
const isLastColumn = this.grid.unpinnedColumns[this.grid.unpinnedColumns.length - 1].visibleIndex === visibleColumnIndex;
436433
if (isSummaryRow && rowIndex === 0 &&
437434
this.grid.unpinnedColumns[this.grid.unpinnedColumns.length - 1].visibleIndex === visibleColumnIndex) {
438435
return;
439436
}
437+
if (nextIsDetailRow && isLastColumn) {
438+
this.navigateDown(currentRowEl, { row: rowIndex, column: visibleColumnIndex });
439+
return;
440+
}
440441

441442
if (this.isRowInEditMode(rowIndex)) {
442443
this.moveNextEditable(rowIndex, visibleColumnIndex);
443444
return;
444445
}
445446

446-
if (this.grid.unpinnedColumns[this.grid.unpinnedColumns.length - 1].visibleIndex === visibleColumnIndex) {
447+
if (isLastColumn) {
447448
const rowEl = this.grid.rowList.find(row => row.index === rowIndex + 1) ?
448449
this.grid.rowList.find(row => row.index === rowIndex + 1) :
449450
this.grid.summariesRowList.find(row => row.index === rowIndex + 1);
@@ -556,6 +557,24 @@ export class IgxGridNavigationService {
556557
return;
557558
}
558559

560+
const prevIsDetailRow = rowIndex > 0 ? this.grid.isDetailRecord(this.grid.dataView[rowIndex - 1]) : false;
561+
if (visibleColumnIndex === 0 && prevIsDetailRow) {
562+
let target = currentRowEl.previousElementSibling;
563+
const applyFocusFunc = () => {
564+
target = this.getRowByIndex(rowIndex - 1, '');
565+
target.focus({ preventScroll: true });
566+
};
567+
if (target) {
568+
applyFocusFunc();
569+
} else {
570+
this.performVerticalScrollToCell(rowIndex - 1, visibleColumnIndex, () => {
571+
applyFocusFunc();
572+
});
573+
}
574+
575+
return;
576+
}
577+
559578
if (visibleColumnIndex === 0) {
560579
if (rowIndex === 0 && this.grid.allowFiltering && this.grid.filterMode === FilterMode.quickFilter) {
561580
this.moveFocusToFilterCell();
@@ -576,13 +595,12 @@ export class IgxGridNavigationService {
576595

577596
public shouldPerformVerticalScroll(targetRowIndex: number, visibleColumnIndex: number): boolean {
578597
const containerTopOffset = parseInt(this.verticalDisplayContainerElement.style.top, 10);
579-
const targetRow = this.grid.summariesRowList.filter(s => s.index !== 0)
580-
.concat(this.grid.rowList.toArray()).find(r => r.index === targetRowIndex);
598+
const targetRow = this.getRowByIndex(targetRowIndex, '') as any;
581599
const rowHeight = this.grid.verticalScrollContainer.getSizeAt(targetRowIndex);
582600
const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0;
583-
const targetEndTopOffset = targetRow ? targetRow.nativeElement.offsetTop + rowHeight + containerTopOffset :
601+
const targetEndTopOffset = targetRow ? targetRow.offsetTop + rowHeight + containerTopOffset :
584602
containerHeight + rowHeight;
585-
if (!targetRow || targetRow.nativeElement.offsetTop < Math.abs(containerTopOffset)
603+
if (!targetRow || targetRow.offsetTop < Math.abs(containerTopOffset)
586604
|| containerHeight && containerHeight < targetEndTopOffset) {
587605
return true;
588606
} else {
@@ -622,13 +640,18 @@ export class IgxGridNavigationService {
622640
}
623641

624642
protected getRowByIndex(index, selector = this.getRowSelector()) {
625-
return this.grid.nativeElement.querySelector(
626-
`${selector}[data-rowindex="${index}"]`);
627-
}
643+
const gridTag = this.grid.nativeElement.tagName.toLocaleLowerCase();
644+
const row = Array.from(this.grid.tbody.nativeElement.querySelectorAll(
645+
`${selector}[data-rowindex="${index}"]`))
646+
.find(x => this.getClosestElemByTag(x, gridTag).getAttribute('id') === this.grid.id);
647+
return row;
648+
}
628649

629650
protected getNextRowByIndex(nextIndex) {
630-
return this.grid.tbody.nativeElement.querySelector(
631-
`[data-rowindex="${nextIndex}"]`);
651+
const gridTag = this.grid.nativeElement.tagName.toLocaleLowerCase();
652+
const row = Array.from(this.grid.tbody.nativeElement.querySelectorAll(
653+
`[data-rowindex="${nextIndex}"]`)).find(x => this.getClosestElemByTag(x, gridTag).getAttribute('id') === this.grid.id);
654+
return row;
632655
}
633656

634657
private getAllRows() {
@@ -637,10 +660,24 @@ export class IgxGridNavigationService {
637660
}
638661

639662
protected getCellSelector(visibleIndex?: number, isSummary = false): string {
663+
if (visibleIndex === 0 && this.grid.hasDetails && !isSummary) {
664+
return 'igx-expandable-grid-cell';
665+
}
640666
return isSummary ? 'igx-grid-summary-cell' : 'igx-grid-cell';
641667
}
642668

643669
protected getRowSelector(): string {
644670
return 'igx-grid-row';
645671
}
672+
673+
protected getClosestElemByTag(sourceElem, targetTag) {
674+
let result = sourceElem;
675+
while (result !== null && result.nodeType === 1) {
676+
if (result.tagName.toLowerCase() === targetTag.toLowerCase()) {
677+
return result;
678+
}
679+
result = result.parentNode;
680+
}
681+
return null;
682+
}
646683
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<ng-template #defaultCell>
2+
<div igxTextHighlight style="pointer-events: none" [cssClass]="highlightClass" [activeCssClass]="activeHighlightClass" [groupName]="gridID"
3+
[value]="formatter ? formatter(value) : column.dataType === 'number' ? (value | igxdecimal: grid.locale) : column.dataType === 'date' ? (value | igxdate: grid.locale) : value"
4+
[row]="rowData" [column]="this.column.field" [containerClass]="'igx-grid__td-text'"
5+
class="igx-grid__td-text">{{ formatter ? formatter(value) : column.dataType === 'number' ? (value | igxdecimal:
6+
grid.locale) : column.dataType === 'date' ? (value | igxdate: grid.locale) : value }}</div>
7+
</ng-template>
8+
<ng-template #inlineEditor let-cell="cell">
9+
<ng-container *ngIf="column.dataType === 'string'">
10+
<igx-input-group displayDensity="compact">
11+
<input igxInput [(ngModel)]="editValue" [igxFocus]="focused">
12+
</igx-input-group>
13+
</ng-container>
14+
<ng-container *ngIf="column.dataType === 'number'">
15+
<igx-input-group displayDensity="compact">
16+
<input igxInput [(ngModel)]="editValue" [igxFocus]="focused" type="number">
17+
</igx-input-group>
18+
</ng-container>
19+
<ng-container *ngIf="column.dataType === 'boolean'">
20+
<igx-checkbox (change)="editValue = $event.checked" [value]="editValue" [checked]="editValue" [disableRipple]="true"></igx-checkbox>
21+
</ng-container>
22+
<ng-container *ngIf="column.dataType === 'date'">
23+
<igx-date-picker [style.width.%]="100" [outlet]="grid.outletDirective" mode="dropdown"
24+
[locale]="grid.locale" [(value)]="editValue" [igxFocus]="focused" [labelVisibility]="false">
25+
</igx-date-picker>
26+
</ng-container>
27+
</ng-template>
28+
<ng-container *ngIf="!editMode">
29+
<div #indicator
30+
class="igx-grid__tree-grouping-indicator"
31+
(click)="toggle($event)" (focus)="onIndicatorFocus()" tabindex="-1">
32+
<ng-container *ngTemplateOutlet="iconTemplate; context: { $implicit: this }">
33+
</ng-container>
34+
</div>
35+
</ng-container>
36+
<ng-container *ngTemplateOutlet="template; context: context">
37+
</ng-container>
38+
<ng-template #defaultExpandedTemplate>
39+
<igx-icon fontSet="material">expand_more</igx-icon>
40+
</ng-template>
41+
<ng-template #defaultCollapsedTemplate>
42+
<igx-icon fontSet="material">chevron_right</igx-icon>
43+
</ng-template>

0 commit comments

Comments
 (0)