Skip to content

Commit

Permalink
feat: Row Detail with inner grids (#1318)
Browse files Browse the repository at this point in the history
* feat: Row Detail with inner grids
  • Loading branch information
ghiscoding authored Mar 1, 2025
1 parent 407430b commit 26990c4
Show file tree
Hide file tree
Showing 20 changed files with 1,220 additions and 166 deletions.
19 changes: 0 additions & 19 deletions docs/grid-functionalities/header-footer-slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,6 @@ You can add Header and/or Footer to your grid by using the `#header` and `#foote

### Basic Usage

##### Component

```vue
<template>
<slickgrid-vue v-model:options="gridOptions" v-model:columns="columnDefinitions" v-model:data="dataset" grid-id="grid2">
<template #header>
<div class="custom-header-slot">
<h3>Grid with header and footer slot</h3>
</div>
</template>
<template #footer>
<div class="custom-footer-slot">
<CustomFooter />
</div>
</template>
</slickgrid-vue>
</template>
```

###### ViewModel

```html
Expand Down
141 changes: 141 additions & 0 deletions docs/grid-functionalities/row-detail.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,144 @@ addNewColumn() {
// this.aureliaGrid.slickGrid.setColumns(cols);
}
```

## Row Detail with Inner Grid

You can also add an inner grid inside a Row Detail, however there are a few things to know off and remember. Any time a Row Detail is falling outside the main grid viewport, it will be unmounted and until it comes back into the viewport which is then remounted. The process of unmounting and remounting means that Row Detail previous states aren't preserved, however you could use Grid State & Presets to overcome this problem.

##### Component

Main Grid Component

```ts
import { bindable } from 'aurelia';
import { type AureliaGridInstance, type Column, ExtensionName, type GridOption, type SlickRowDetailView, } from 'aurelia-slickgrid';

export class MainGrid implements OnInit {
columnDefinitions: Column[] = [];
gridOptions!: GridOption;
aureliaGrid!: AureliaGridInstance;
dataset: Distributor[] = [];
showSubTitle = true;

get rowDetailInstance(): SlickRowDetailView {
return this.aureliaGrid?.extensionService.getExtensionInstanceByName(ExtensionName.rowDetailView);
}

aureliaGridReady(aureliaGrid: AureliaGridInstance) {
this.aureliaGrid = aureliaGrid;
}

constructor() {
this.defineGrid();
}

attached() {
this.dataset = this.getData();
}

defineGrid() {
this.columnDefinitions = [ /*...*/ ];
this.gridOptions = {
enableRowDetailView: true,
rowSelectionOptions: {
selectActiveRow: true
},
preRegisterExternalExtensions: (pubSubService) => {
// Row Detail View is a special case because of its requirement to create extra column definition dynamically
// so it must be pre-registered before SlickGrid is instantiated, we can do so via this option
const rowDetail = new SlickRowDetailView(pubSubService as EventPubSubService);
return [{ name: ExtensionName.rowDetailView, instance: rowDetail }];
},
rowDetailView: {
process: (item: any) => simulateServerAsyncCall(item),
loadOnce: false, // IMPORTANT, you can't use loadOnce with inner grid because only HTML template are re-rendered, not JS events
panelRows: 10,
preloadComponent: PreloadComponent,
viewComponent: InnerGridComponent,
},
};
}
}
```

Now, let's define our Inner Grid Component

```html
<div class.bind="innerGridClass">
<h4>Order Details (id: ${ model.id })</h4>
<div class="container-fluid">
<aurelia-slickgrid
grid-id.bind="gridId"
column-definitions.bind="innerColDefs"
grid-options.bind="innerGridOptions"
dataset.bind="innerDataset"
instances.bind="aureliaGrid"
on-aurelia-grid-created.trigger="aureliaGridReady($event.detail)"
on-before-grid-destroy.trigger="handleBeforeGridDestroy()">
</aurelia-slickgrid>
</div>
</div>

```

```ts
import { bindable } from 'aurelia';
import { type AureliaGridInstance, type Column, ExtensionName, type GridOption, type SlickRowDetailView, } from 'aurelia-slickgrid';

export interface Distributor { /* ... */ }
export interface OrderData { /* ... */ }

export class InnerGridComponent {
@bindable() model!: Distributor;
innerColDefs: Column[] = [];
innerGridOptions!: GridOption;
angularGrid!: AngularGridInstance;
innerDataset: any[] = [];
innerGridId = '';
innerGridClass = '';

attached() {
this.gridId = `innergrid-${this.model.id}`;
this.innerGridClass = `row-detail-${this.model.id}`;
this.defineGrid();
this.innerDataset = [...this.model.orderData];
}

aureliaGridReady(aureliaGrid: AureliaGridInstance) {
this.aureliaGrid = aureliaGrid;
}

defineGrid() {
// OPTIONALLY reapply Grid State as Presets before unmounting the compoment
let gridState: GridState | undefined;
const gridStateStr = sessionStorage.getItem(`gridstate_${this.innerGridClass}`);
if (gridStateStr) {
gridState = JSON.parse(gridStateStr);
}

this.innerColDefs = [
{ id: 'orderId', field: 'orderId', name: 'Order ID', filterable: true, sortable: true },
{ id: 'shipCity', field: 'shipCity', name: 'Ship City', filterable: true, sortable: true },
{ id: 'freight', field: 'freight', name: 'Freight', filterable: true, sortable: true, type: 'number' },
{ id: 'shipName', field: 'shipName', name: 'Ship Name', filterable: true, sortable: true },
];

this.innerGridOptions = {
autoResize: {
container: `.${this.innerGridClass}`,
},
enableFiltering: true,
enableSorting: true,
datasetIdPropertyName: 'orderId',
presets: gridState, // reapply grid state presets
};
}

// OPTIONALLY save Grid State before unmounting the compoment
handleBeforeGridDestroy() {
const gridState = this.angularGrid.gridStateService.getCurrentGridState();
sessionStorage.setItem(`gridstate_${this.innerGridClass}`, JSON.stringify(gridState));
}
}
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"@jest/types": "^29.6.3",
"@lerna-lite/cli": "^3.12.0",
"@lerna-lite/publish": "^3.12.0",
"@slickgrid-universal/common": "^5.12.2",
"@slickgrid-universal/common": "^5.13.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.1",
"conventional-changelog-conventionalcommits": "^7.0.2",
Expand Down
12 changes: 6 additions & 6 deletions packages/aurelia-slickgrid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@
"@aurelia/runtime": "^2.0.0-beta.23",
"@aurelia/runtime-html": "^2.0.0-beta.23",
"@formkit/tempo": "^0.1.2",
"@slickgrid-universal/common": "~5.12.2",
"@slickgrid-universal/custom-footer-component": "~5.12.2",
"@slickgrid-universal/empty-warning-component": "~5.12.2",
"@slickgrid-universal/event-pub-sub": "~5.12.2",
"@slickgrid-universal/pagination-component": "~5.12.2",
"@slickgrid-universal/row-detail-view-plugin": "~5.12.2",
"@slickgrid-universal/common": "~5.13.0",
"@slickgrid-universal/custom-footer-component": "~5.13.0",
"@slickgrid-universal/empty-warning-component": "~5.13.0",
"@slickgrid-universal/event-pub-sub": "~5.13.0",
"@slickgrid-universal/pagination-component": "~5.13.0",
"@slickgrid-universal/row-detail-view-plugin": "~5.13.0",
"@slickgrid-universal/utils": "~5.12.0",
"dequal": "^2.0.3",
"sortablejs": "^1.15.6"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -747,8 +747,13 @@ export class AureliaSlickgridCustomElement {
this.loadFilterPresetsWhenDatasetInitialized();

// When data changes in the DataView, we need to refresh the metrics and/or display a warning if the dataset is empty
this._eventHandler.subscribe(dataView.onRowCountChanged, () => {
grid.invalidate();
this._eventHandler.subscribe(dataView.onRowCountChanged, (_e, args) => {
if (!gridOptions.enableRowDetailView || !Array.isArray(args.changedRows) || args.changedRows.length === args.itemCount) {
grid.invalidate();
} else {
grid.invalidateRows(args.changedRows);
grid.render();
}
this.handleOnItemCountChanged(dataView.getFilteredItemCount() || 0, dataView.getItemCount() || 0);
});
this._eventHandler.subscribe(dataView.onSetItemsCalled, (_e, args) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ const gridOptionsMock: Partial<GridOption> = {
panelRows: 1,
keyPrefix: '__',
useRowClick: true,
useSimpleViewportCalc: true,
saveDetailViewOnScroll: false,
process: () => new Promise((resolve) => resolve('process resolving')),
// @ts-ignore
Expand Down Expand Up @@ -563,31 +562,6 @@ describe('SlickRowDetailView', () => {
expect(handlerSpy).toHaveBeenCalled();
});

it('should call "redrawViewSlot" when grid event "onRowBackToViewportRange" is triggered', () => {
const mockColumn = { id: 'field1', field: 'field1', width: 100, cssClass: 'red', __collapsed: true };
const handlerSpy = jest.spyOn(plugin.eventHandler, 'subscribe');
// @ts-ignore:2345
const appendSpy = jest.spyOn(aureliaUtilServiceStub, 'createAureliaViewModelAddToSlot').mockReturnValue({ controller: { deactivate: jest.fn() } });
const redrawSpy = jest.spyOn(plugin, 'redrawAllViewSlots');

plugin.init(gridStub);
plugin.onBeforeRowDetailToggle = new SlickEvent();
plugin.onRowBackToViewportRange = new SlickEvent();
plugin.register();
plugin.onRowBackToViewportRange.subscribe(() => {
expect(appendSpy).toHaveBeenCalledWith(
ExampleLoader,
expect.objectContaining({ model: mockColumn, addon: expect.anything(), grid: gridStub, }),
expect.objectContaining({ className: 'container_field1' })
);
expect(redrawSpy).toHaveBeenCalled();
});
plugin.onBeforeRowDetailToggle.notify({ item: mockColumn, grid: gridStub } as any, new SlickEventData(), gridStub);
plugin.onRowBackToViewportRange.notify({ item: mockColumn, grid: gridStub } as any, new SlickEventData(), gridStub);

expect(handlerSpy).toHaveBeenCalled();
});

it('should run the internal "onProcessing" and call "notifyTemplate" with a Promise when "process" method is defined and executed', (done) => {
const mockItem = { id: 2, firstName: 'John', lastName: 'Doe' };
gridOptionsMock.rowDetailView!.process = () => new Promise((resolve) => resolve(mockItem));
Expand Down
55 changes: 39 additions & 16 deletions packages/aurelia-slickgrid/src/extensions/slickRowDetailView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {

/** Dispose of all the opened Row Detail Panels Aurelia View Slots */
disposeAllViewSlot() {
if (Array.isArray(this._slots)) {
this._slots.forEach((slot) => this.disposeViewSlot(slot));
}
this._slots = [];
do {
const view = this._slots.pop();
if (view) {
this.disposeView(view);
}
} while (this._slots.length > 0);
}

/** Get the instance of the SlickGrid addon (control or plugin). */
Expand Down Expand Up @@ -157,7 +159,6 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {
// display preload template & re-render all the other Detail Views after toggling
// the preload View will eventually go away once the data gets loaded after the "onAsyncEndUpdate" event
await this.renderPreloadView();
this.renderAllViewModels();

if (typeof this.rowDetailViewOptions?.onAfterRowDetailToggle === 'function') {
this.rowDetailViewOptions.onAfterRowDetailToggle(event, args);
Expand Down Expand Up @@ -188,6 +189,15 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {
});
}

if (this.onBeforeRowOutOfViewportRange) {
this._eventHandler.subscribe(this.onBeforeRowOutOfViewportRange, (event, args) => {
if (typeof this.rowDetailViewOptions?.onBeforeRowOutOfViewportRange === 'function') {
this.rowDetailViewOptions.onBeforeRowOutOfViewportRange(event, args);
}
this.disposeView(args.item);
});
}

if (this.onRowOutOfViewportRange) {
this._eventHandler.subscribe(this.onRowOutOfViewportRange, (event, args) => {
if (typeof this.rowDetailViewOptions?.onRowOutOfViewportRange === 'function') {
Expand Down Expand Up @@ -224,12 +234,19 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {

/** Redraw (re-render) all the expanded row detail View Slots */
async redrawAllViewSlots() {
await Promise.all(this._slots.map(async x => this.redrawViewSlot(x)));
this.resetRenderedRows();
const promises: Promise<void>[] = [];
this._slots.forEach((x) => promises.push(this.redrawViewSlot(x)));
await Promise.all(promises);
}

/** Render all the expanded row detail View Slots */
async renderAllViewModels() {
await Promise.all(this._slots.filter(x => x?.dataContext).map(async x => this.renderViewModel(x.dataContext)));
const promises: Promise<void>[] = [];
Array.from(this._slots)
.filter((x) => x?.dataContext)
.forEach((x) => promises.push(this.renderViewModel(x.dataContext)));
await Promise.all(promises);
}

/** Redraw the necessary View Slot */
Expand Down Expand Up @@ -274,6 +291,15 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {
// protected functions
// ------------------

protected disposeView(item: any, removeFromArray = false): void {
const foundSlotIndex = this._slots.findIndex((slot: CreatedView) => slot.id === item[this.datasetIdPropName]);
if (foundSlotIndex >= 0 && this.disposeViewSlot(this._slots[foundSlotIndex])) {
if (removeFromArray) {
this._slots.splice(foundSlotIndex, 1);
}
}
}

protected disposeViewSlot(expandedView: CreatedView): CreatedView | void {
if (expandedView?.controller) {
const container = this.gridContainerElement.getElementsByClassName(`${ROW_DETAIL_CONTAINER_PREFIX}${expandedView.id}`);
Expand All @@ -297,16 +323,12 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {
// expanding row detail
const viewInfo: CreatedView = {
id: args.item[this.datasetIdPropName],
dataContext: args.item
dataContext: args.item,
};
const idPropName = this.gridOptions.datasetIdPropertyName || 'id';
addToArrayWhenNotExists(this._slots, viewInfo, idPropName);
addToArrayWhenNotExists(this._slots, viewInfo, this.datasetIdPropName);
} else {
// collapsing, so dispose of the View/ViewSlot
const foundSlotIndex = this._slots.findIndex((slot: CreatedView) => slot.id === args.item[this.datasetIdPropName]);
if (foundSlotIndex >= 0 && this.disposeViewSlot(this._slots[foundSlotIndex])) {
this._slots.splice(foundSlotIndex, 1);
}
this.disposeView(args.item, true);
}
}

Expand All @@ -319,8 +341,9 @@ export class SlickRowDetailView extends UniversalSlickRowDetailView {
rowIdsOutOfViewport: (string | number)[];
grid: SlickGrid;
}) {
if (args?.item) {
await this.redrawAllViewSlots();
const slot = Array.from(this._slots).find((x) => x.id === args.rowId);
if (slot) {
this.redrawViewSlot(slot);
}
}

Expand Down
1 change: 0 additions & 1 deletion packages/aurelia-slickgrid/src/global-grid-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ export const GlobalGridOptions: Partial<GridOption> = {
panelRows: 1,
keyPrefix: '__',
useRowClick: false,
useSimpleViewportCalc: true,
saveDetailViewOnScroll: false,
} as RowDetailView,
headerRowHeight: 35,
Expand Down
18 changes: 9 additions & 9 deletions packages/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@
"@fnando/sparkline": "^0.3.10",
"@formkit/tempo": "^0.1.2",
"@popperjs/core": "^2.11.8",
"@slickgrid-universal/common": "^5.12.2",
"@slickgrid-universal/composite-editor-component": "^5.12.2",
"@slickgrid-universal/custom-tooltip-plugin": "^5.12.2",
"@slickgrid-universal/excel-export": "^5.12.2",
"@slickgrid-universal/graphql": "^5.12.2",
"@slickgrid-universal/odata": "^5.12.2",
"@slickgrid-universal/row-detail-view-plugin": "^5.12.2",
"@slickgrid-universal/rxjs-observable": "^5.12.2",
"@slickgrid-universal/text-export": "^5.12.2",
"@slickgrid-universal/common": "^5.13.0",
"@slickgrid-universal/composite-editor-component": "^5.13.0",
"@slickgrid-universal/custom-tooltip-plugin": "^5.13.0",
"@slickgrid-universal/excel-export": "^5.13.0",
"@slickgrid-universal/graphql": "^5.13.0",
"@slickgrid-universal/odata": "^5.13.0",
"@slickgrid-universal/row-detail-view-plugin": "^5.13.0",
"@slickgrid-universal/rxjs-observable": "^5.13.0",
"@slickgrid-universal/text-export": "^5.13.0",
"aurelia": "^2.0.0-beta.23",
"aurelia-slickgrid": "workspace:*",
"bootstrap": "^5.3.3",
Expand Down
Loading

0 comments on commit 26990c4

Please sign in to comment.