@slickgrid-universal/row-detail-plugin 0.0.0 → 11.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1200 @@
1
+ import { createDomElement, SlickEvent, SlickEventHandler, Utils as SlickUtils } from '@slickgrid-universal/common';
2
+ import type {
3
+ Column,
4
+ ExternalResource,
5
+ FormatterResultWithHtml,
6
+ GridOption,
7
+ OnAfterRowDetailToggleArgs,
8
+ OnBeforeRowDetailToggleArgs,
9
+ OnRowBackOrOutOfViewportRangeArgs,
10
+ OnRowDetailAsyncEndUpdateArgs,
11
+ OnRowDetailAsyncResponseArgs,
12
+ PubSubService,
13
+ RowDetailView,
14
+ RowDetailViewOption,
15
+ SlickDataView,
16
+ SlickEventData,
17
+ SlickGrid,
18
+ SlickRowDetailView as UniversalRowDetailView,
19
+ UsabilityOverrideFn,
20
+ } from '@slickgrid-universal/common';
21
+ import { classNameToList, extend } from '@slickgrid-universal/utils';
22
+
23
+ /**
24
+ * A plugin to add Row Detail Panel View (for example providing order detail info when clicking on the order row in the grid)
25
+ * Original StackOverflow question & article making this possible (thanks to violet313)
26
+ * https://stackoverflow.com/questions/10535164/can-slickgrids-row-height-be-dynamically-altered#29399927
27
+ * http://violet313.org/slickgrids/#intro
28
+ */
29
+ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailView {
30
+ // --
31
+ // public API
32
+ pluginName = 'RowDetailView';
33
+
34
+ /** Fired when the async response finished */
35
+ onAsyncEndUpdate: SlickEvent<OnRowDetailAsyncEndUpdateArgs>;
36
+
37
+ /** This event must be used with the "notify" by the end user once the Asynchronous Server call returns the item detail */
38
+ onAsyncResponse: SlickEvent<OnRowDetailAsyncResponseArgs>;
39
+
40
+ /** Fired after the row detail gets toggled */
41
+ onAfterRowDetailToggle: SlickEvent<OnAfterRowDetailToggleArgs>;
42
+
43
+ /** Fired before the row detail gets toggled */
44
+ onBeforeRowDetailToggle: SlickEvent<OnBeforeRowDetailToggleArgs>;
45
+
46
+ /** Fired just before a row becomes out of viewport range (you can use this event to save inner Grid State before it gets destroyed) */
47
+ onBeforeRowOutOfViewportRange: SlickEvent<OnRowBackOrOutOfViewportRangeArgs>;
48
+
49
+ /** Fired after the row detail gets toggled */
50
+ onRowBackToViewportRange: SlickEvent<OnRowBackOrOutOfViewportRangeArgs>;
51
+
52
+ /** Fired after a row becomes out of viewport range (when user can't see the row anymore) */
53
+ onRowOutOfViewportRange: SlickEvent<OnRowBackOrOutOfViewportRangeArgs>;
54
+
55
+ // --
56
+ // protected props
57
+ protected _addonOptions!: RowDetailView;
58
+ protected _dataViewIdProperty = 'id';
59
+ protected _eventHandler: SlickEventHandler;
60
+ protected _expandableOverride: UsabilityOverrideFn | null = null;
61
+ protected _expandedRowIds: Set<number | string> = new Set();
62
+ protected _grid!: SlickGrid;
63
+ protected _gridRowBuffer = 0;
64
+ protected _gridUid = '';
65
+ protected _keyPrefix = '';
66
+ protected _disposedRows: Set<number> = new Set();
67
+ protected _rowIdsOutOfViewport: Set<number | string> = new Set();
68
+ protected _renderedViewportRowIds: Set<number | string> = new Set();
69
+ protected _pendingBackToViewportRows: Map<number | string, any> = new Map();
70
+ protected _renderedCollapsedGroupIds: Set<number | string> = new Set();
71
+ protected _renderedIds: Set<number | string> = new Set();
72
+ protected _overlayPanels: Map<number | string, HTMLDivElement> = new Map();
73
+ protected _pendingAsyncEndUpdates: Map<number | string, { eventData: SlickEventData; item: any; isDataViewUpdated: boolean }> = new Map();
74
+ protected _isUpdatingAsyncResponse = false;
75
+ protected _visibleRenderedCell?: { startRow: number; endRow: number };
76
+ protected _backViewportTimer: any;
77
+ protected _lastScrollLeft = 0;
78
+ protected _defaults = {
79
+ alwaysRenderColumn: true,
80
+ columnId: '_detail_selector',
81
+ field: '_detail_selector',
82
+ cssClass: 'detailView-toggle',
83
+ collapseAllOnSort: true,
84
+ collapsedClass: undefined,
85
+ expandedClass: undefined,
86
+ keyPrefix: '__',
87
+ loadOnce: false,
88
+ maxRows: undefined,
89
+ reorderable: false,
90
+ saveDetailViewOnScroll: true,
91
+ singleRowExpand: false,
92
+ toolTip: '',
93
+ width: 30,
94
+ } as unknown as RowDetailView;
95
+
96
+ /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */
97
+ constructor(protected readonly pubSubService: PubSubService) {
98
+ this._eventHandler = new SlickEventHandler();
99
+ this.onAsyncEndUpdate = new SlickEvent<OnRowDetailAsyncEndUpdateArgs>('onAsyncEndUpdate');
100
+ this.onAsyncResponse = new SlickEvent<OnRowDetailAsyncResponseArgs>('onAsyncResponse');
101
+ this.onAfterRowDetailToggle = new SlickEvent<OnAfterRowDetailToggleArgs>('onAfterRowDetailToggle');
102
+ this.onBeforeRowDetailToggle = new SlickEvent<OnBeforeRowDetailToggleArgs>('onBeforeRowDetailToggle');
103
+ this.onBeforeRowOutOfViewportRange = new SlickEvent<OnRowBackOrOutOfViewportRangeArgs>('onBeforeRowOutOfViewportRange');
104
+ this.onRowBackToViewportRange = new SlickEvent<OnRowBackOrOutOfViewportRangeArgs>('onRowBackToViewportRange');
105
+ this.onRowOutOfViewportRange = new SlickEvent<OnRowBackOrOutOfViewportRangeArgs>('onRowOutOfViewportRange');
106
+ }
107
+
108
+ get addonOptions(): RowDetailView {
109
+ return this._addonOptions;
110
+ }
111
+
112
+ /** Getter of SlickGrid DataView object */
113
+ get dataView(): SlickDataView {
114
+ return this._grid?.getData<SlickDataView>() || {};
115
+ }
116
+
117
+ get dataViewIdProperty(): string {
118
+ return this._dataViewIdProperty;
119
+ }
120
+
121
+ get eventHandler(): SlickEventHandler {
122
+ return this._eventHandler;
123
+ }
124
+
125
+ /** Getter for the Grid Options pulled through the Grid Object */
126
+ get gridOptions(): GridOption {
127
+ return this._grid?.getOptions() || {};
128
+ }
129
+
130
+ get gridUid(): string {
131
+ return this._gridUid || this._grid?.getUID() || '';
132
+ }
133
+
134
+ set rowIdsOutOfViewport(rowIds: Array<string | number>) {
135
+ this._rowIdsOutOfViewport = new Set(rowIds);
136
+ }
137
+
138
+ /**
139
+ * Initialize the Export Service
140
+ * @param _grid
141
+ */
142
+ init(grid: SlickGrid): void {
143
+ if (!grid) {
144
+ throw new Error(
145
+ '[Slickgrid-Universal] RowDetailView Plugin requires the Grid instance to be passed as argument to the "init()" method.'
146
+ );
147
+ }
148
+ this._grid = grid;
149
+ this._gridUid = grid.getUID();
150
+ if (!this._addonOptions) {
151
+ this._addonOptions = extend(true, {}, this._defaults, this.gridOptions.rowDetailView) as RowDetailView;
152
+ }
153
+ this._keyPrefix = this._addonOptions?.keyPrefix || '__';
154
+
155
+ // add PubSub instance to all SlickEvent
156
+ SlickUtils.addSlickEventPubSubWhenDefined(this.pubSubService, this);
157
+
158
+ // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
159
+ this._gridRowBuffer = this.gridOptions.minRowBuffer || 0;
160
+ this.gridOptions.minRowBuffer = this._addonOptions.panelRows + 3;
161
+
162
+ this._eventHandler
163
+ .subscribe(this._grid.onClick, this.handleClick.bind(this))
164
+ .subscribe(this._grid.onKeyDown, this.handleKeyDown.bind(this))
165
+ .subscribe(this._grid.onBeforeEditCell, () => this.collapseAll())
166
+ .subscribe(this._grid.onScroll, (_e, { scrollLeft }) => {
167
+ this.recalculateOutOfRangeViews(true, 0, false);
168
+ // Vertical rendering positions panels; horizontal scrolling can happen without a render.
169
+ if (scrollLeft !== this._lastScrollLeft) {
170
+ this._lastScrollLeft = scrollLeft;
171
+ this.renderOverlayPanels();
172
+ }
173
+ })
174
+ .subscribe(this._grid.onBeforeRemoveCachedRow, (_e, args) => this.handleRemoveRow(args.row))
175
+
176
+ // Grouping events should trigger `onRowOutOfViewportRange` or `onRowBackToViewportRange`
177
+ .subscribe(this.dataView.onGroupCollapsed, (_e, { groupingKey }) => this.handleGroupCollapsed(groupingKey))
178
+ .subscribe(this.dataView.onGroupExpanded, (_e, { groupingKey }) => this.handleGroupExpanded(groupingKey));
179
+
180
+ // Sort will, by default, Collapse all of the open items (unless user implements his own onSort which deals with open row and padding)
181
+ if (this._addonOptions.collapseAllOnSort) {
182
+ // sort event can be triggered by column header click or from header menu
183
+ this.pubSubService.subscribe('onSortChanged', () => this.collapseAll());
184
+ }
185
+
186
+ this._eventHandler.subscribe(this.dataView.onRowCountChanged, () => {
187
+ // filtering/sorting can remove an expanded row's parent from the DataView, hide its Row Detail without collapsing it
188
+ // this runs synchronously as part of the DataView refresh(), ahead of any framework-specific "onFilterChanged" redraw
189
+ this.hideRowDetailsForFilteredOutParents();
190
+ this._grid.updateRowCount();
191
+ this._grid.render();
192
+ // re-show any Row Detail whose parent row is back in the filtered/sorted dataset and within the rendered range
193
+ this.recalculateOutOfRangeViews(true);
194
+ });
195
+
196
+ this._eventHandler.subscribe(this.dataView.onRowsChanged, (_e, args) => {
197
+ // filtering/sorting can remove an expanded row's parent from the DataView, hide its Row Detail without collapsing it
198
+ this.hideRowDetailsForFilteredOutParents();
199
+
200
+ // A suspended DataView only emits this event when endUpdate() releases its
201
+ // accumulated changes. Pending details are safe to finish on the grid render
202
+ // caused by this notification, not on an unrelated render in the meantime.
203
+ this._pendingAsyncEndUpdates.forEach((pendingUpdate) => (pendingUpdate.isDataViewUpdated = true));
204
+
205
+ const cachedRows = Object.keys(this._grid.getRowCache()).map(Number);
206
+ const toInvalidateRows: number[] = [];
207
+ const intersectedRows = args.rows.filter((nb) => cachedRows.includes(nb));
208
+
209
+ // only consider rows to invalidate as rows that exists in the viewport (cached rows)
210
+ this._expandedRowIds.forEach((itemId) => {
211
+ const idx = this.dataView.getRowById(itemId);
212
+ if (idx !== undefined && intersectedRows.includes(idx)) {
213
+ toInvalidateRows.push(idx);
214
+ }
215
+ });
216
+
217
+ // don't invalidate row detail that were already rendered and visible
218
+ // for example, if we open row 3 and then row 1, row 3 will be pushed down but it was already rendered so no need to re-render it
219
+ this._renderedIds.forEach((rowId) => {
220
+ const dataRowIdx = this.dataView.getRowById(rowId);
221
+ if (dataRowIdx !== undefined) {
222
+ const invRowIdx = toInvalidateRows.findIndex((r) => r === dataRowIdx);
223
+ if (invRowIdx >= 0) {
224
+ toInvalidateRows.splice(invRowIdx, 1);
225
+ }
226
+ }
227
+ });
228
+
229
+ this._grid.invalidateRows(toInvalidateRows);
230
+ this._grid.render();
231
+ // re-show any Row Detail whose parent row is back in the filtered/sorted dataset and within the rendered range
232
+ this.recalculateOutOfRangeViews(true);
233
+ });
234
+
235
+ // subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
236
+ this._eventHandler.subscribe(this.onAsyncResponse, this.handleOnAsyncResponse.bind(this));
237
+
238
+ // after data is set, let's get the DataView Id Property name used (defaults to "id")
239
+ this._eventHandler.subscribe(this.dataView.onSetItemsCalled, () => {
240
+ this._dataViewIdProperty = this.dataView.getIdPropertyName() || 'id';
241
+ });
242
+
243
+ this._eventHandler.subscribe(this._grid.onRendered, () => {
244
+ this.renderOverlayPanels();
245
+ this._pendingBackToViewportRows.forEach((item) => this.notifyBackToViewportWhenDomExist(item));
246
+ if (!this._isUpdatingAsyncResponse) {
247
+ this.flushPendingAsyncEndUpdates();
248
+ }
249
+ });
250
+ }
251
+
252
+ /** Dispose of the Slick Row Detail View */
253
+ dispose(): void {
254
+ this.disposeOverlayPanels();
255
+ this._eventHandler?.unsubscribeAll();
256
+ this._expandedRowIds.clear();
257
+ this._rowIdsOutOfViewport.clear();
258
+ this._renderedViewportRowIds.clear();
259
+ this._pendingBackToViewportRows.clear();
260
+ this._pendingAsyncEndUpdates.clear();
261
+ clearTimeout(this._backViewportTimer);
262
+ }
263
+
264
+ create(columns: Column[], gridOptions: GridOption): UniversalRowDetailView | null {
265
+ if (!gridOptions.rowDetailView) {
266
+ throw new Error(
267
+ '[Slickgrid-Universal] The Row Detail View requires options to be passed via the "rowDetailView" property of the Grid Options'
268
+ );
269
+ }
270
+
271
+ this._addonOptions = extend(true, {}, this._defaults, gridOptions.rowDetailView) as RowDetailView;
272
+
273
+ // user could override the expandable icon logic from within the options or after instantiating the plugin
274
+ if (typeof this._addonOptions.expandableOverride === 'function') {
275
+ this.expandableOverride(this._addonOptions.expandableOverride);
276
+ }
277
+
278
+ if (Array.isArray(columns) && gridOptions) {
279
+ const newRowDetailViewColumn: Column = this.getColumnDefinition();
280
+
281
+ // add new row detail column unless it was already added
282
+ if (!columns.some((col) => col.id === newRowDetailViewColumn.id)) {
283
+ const rowDetailColDef = Array.isArray(columns) && columns.find((col) => col?.behavior === 'selectAndMove');
284
+ const finalRowDetailViewColumn = rowDetailColDef ? rowDetailColDef : newRowDetailViewColumn;
285
+
286
+ // column index position in the grid
287
+ const columnPosition = gridOptions?.rowDetailView?.columnIndexPosition ?? 0;
288
+ if (columnPosition > 0) {
289
+ columns.splice(columnPosition, 0, finalRowDetailViewColumn);
290
+ } else {
291
+ columns.unshift(finalRowDetailViewColumn);
292
+ }
293
+
294
+ this.pubSubService.publish(`onPluginColumnsChanged`, {
295
+ columns,
296
+ pluginName: this.pluginName,
297
+ });
298
+ }
299
+ }
300
+ return this as unknown as UniversalRowDetailView;
301
+ }
302
+
303
+ /** Get current plugin options */
304
+ getOptions(): RowDetailViewOption {
305
+ return this._addonOptions;
306
+ }
307
+
308
+ /** set or change some of the plugin options */
309
+ setOptions(options: Partial<RowDetailViewOption>): void {
310
+ this._addonOptions = extend(true, {}, this._addonOptions, options) as RowDetailView;
311
+ if (this._addonOptions?.singleRowExpand) {
312
+ this.collapseAll();
313
+ }
314
+ }
315
+
316
+ /** Collapse all of the open items */
317
+ collapseAll(): void {
318
+ this.dataView.beginUpdate();
319
+ this._expandedRowIds.forEach((itemId) => {
320
+ this.collapseDetailView(itemId, true);
321
+ });
322
+ this.dataView.endUpdate();
323
+ }
324
+
325
+ /** Collapse an Item so it is not longer seen */
326
+ collapseDetailView(itemId: number | string, isMultipleCollapsing = false): void {
327
+ const item = this.dataView.getItemById(itemId);
328
+ if (item) {
329
+ if (!isMultipleCollapsing) {
330
+ this.dataView.beginUpdate();
331
+ }
332
+ // Save the details on the collapse assuming onetime loading
333
+ if (this._addonOptions.loadOnce) {
334
+ this.saveDetailView(item);
335
+ }
336
+
337
+ item[`${this._keyPrefix}collapsed`] = true;
338
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
339
+ this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
340
+ }
341
+ item[`${this._keyPrefix}sizePadding`] = 0;
342
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
343
+
344
+ // Remove the item from the expandedRows & renderedIds
345
+ this._expandedRowIds = new Set(Array.from(this._expandedRowIds).filter((expItemId) => expItemId !== item[this._dataViewIdProperty]));
346
+ this._renderedIds.delete(item[this._dataViewIdProperty]);
347
+ this._pendingBackToViewportRows.delete(item[this._dataViewIdProperty]);
348
+ this.removeOverlayPanel(item[this._dataViewIdProperty]);
349
+
350
+ // we need to reevaluate & invalidate any row detail that are shown on top of the row that we're closing
351
+ this.reevaluateRenderedRowIds(item);
352
+
353
+ if (!isMultipleCollapsing) {
354
+ this.dataView.endUpdate();
355
+ }
356
+ }
357
+ }
358
+
359
+ /** Expand a row given the dataview item that is to be expanded */
360
+ expandDetailView(itemId: number | string): void {
361
+ const item = this.dataView.getItemById(itemId);
362
+ if (item && !item[`${this._keyPrefix}isPadding`]) {
363
+ if (this._addonOptions?.singleRowExpand) {
364
+ this.collapseAll();
365
+ }
366
+
367
+ // we need to reevaluate & invalidate any row detail that are shown on top of the row that we're closing
368
+ this.reevaluateRenderedRowIds(item);
369
+
370
+ // a new expanded row is considered both expanded & rendered
371
+ item[`${this._keyPrefix}collapsed`] = false;
372
+ this._expandedRowIds.add(itemId);
373
+ this._renderedViewportRowIds.add(itemId);
374
+
375
+ // in the case something went wrong loading it the first time such a scroll of screen before loaded
376
+ if (!item[`${this._keyPrefix}detailContent`]) {
377
+ item[`${this._keyPrefix}detailViewLoaded`] = false;
378
+ }
379
+
380
+ // display pre-loading template
381
+ if (!item[`${this._keyPrefix}detailViewLoaded`] || this._addonOptions.loadOnce !== true) {
382
+ item[`${this._keyPrefix}detailViewLoaded`] = false;
383
+ item[`${this._keyPrefix}detailContent`] = this._addonOptions?.preTemplate?.(item);
384
+ } else {
385
+ this.onAsyncResponse.notify({
386
+ item,
387
+ detailView: item[`${this._keyPrefix}detailContent`],
388
+ grid: this._grid,
389
+ });
390
+ this.applyTemplateNewLineHeight(item);
391
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
392
+ return;
393
+ }
394
+
395
+ this.applyTemplateNewLineHeight(item);
396
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
397
+
398
+ // async server call
399
+ this._addonOptions.process(item);
400
+ }
401
+ }
402
+
403
+ /** Reset disposed rows while preserving mounted overlays and their viewport state. */
404
+ resetRenderedRows(): void {
405
+ // Overlay panels live outside row DOM and must remain mounted while framework adapters
406
+ // redraw their nested views; their viewport state is updated by the next recalc/render.
407
+ this._disposedRows.clear();
408
+ }
409
+
410
+ /** Saves the current state of the detail view */
411
+ saveDetailView(item: any): void {
412
+ if (this._addonOptions.loadOnce && item) {
413
+ const view = document.querySelector(`.${this.gridUid} .row-detail-${item[this._dataViewIdProperty]}`);
414
+ if (view) {
415
+ const html = view.innerHTML;
416
+ if (html !== undefined) {
417
+ item[`${this._keyPrefix}detailContent`] = html;
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
425
+ * the response has to be as "args.item" with it's data back
426
+ */
427
+ handleOnAsyncResponse(e: SlickEventData, args: { item: any; detailView?: any }): void {
428
+ if (!args || !args.item) {
429
+ console.error('SlickRowDetailView plugin requires the onAsyncResponse() to supply "args.item" property.');
430
+ return;
431
+ }
432
+
433
+ // get item detail argument
434
+ const itemDetail = args.item;
435
+ const itemId = itemDetail[this._dataViewIdProperty];
436
+ this.beforeDetailViewRender(itemDetail);
437
+
438
+ // if we just want to load in a view directly we can use detailView property to do so
439
+ itemDetail[`${this._keyPrefix}detailContent`] = args.detailView ?? this._addonOptions?.postTemplate?.(itemDetail);
440
+ itemDetail[`${this._keyPrefix}detailViewLoaded`] = true;
441
+ // DataView updates can be suspended by beginUpdate()/endUpdate(). Keep the response
442
+ // pending before updateItem() so onRowsChanged can identify when that update is
443
+ // released and the following grid render can safely mount the framework view.
444
+ this._pendingAsyncEndUpdates.set(itemId, {
445
+ eventData: e,
446
+ item: itemDetail,
447
+ isDataViewUpdated: false,
448
+ });
449
+ this._isUpdatingAsyncResponse = true;
450
+ try {
451
+ this.dataView.updateItem(itemDetail[this._dataViewIdProperty], itemDetail);
452
+ } finally {
453
+ this._isUpdatingAsyncResponse = false;
454
+ }
455
+ this.flushPendingAsyncEndUpdates();
456
+ }
457
+
458
+ /**
459
+ * Override the logic for showing (or not) the expand icon (use case example: only every 2nd row is expandable)
460
+ * Method that user can pass to override the default behavior or making every row an expandable row.
461
+ * In order word, user can choose which rows to be an available row detail (or not) by providing his own logic.
462
+ * @param overrideFn: override function callback
463
+ */
464
+ expandableOverride(overrideFn: UsabilityOverrideFn): void {
465
+ this._expandableOverride = overrideFn;
466
+ }
467
+
468
+ getExpandableOverride(): UsabilityOverrideFn | null {
469
+ return this._expandableOverride;
470
+ }
471
+
472
+ /** Get the Column Definition of the first column dedicated to toggling the Row Detail View */
473
+ getColumnDefinition(): Column {
474
+ const columnId = String(this._addonOptions?.columnId ?? this._defaults.columnId);
475
+
476
+ return {
477
+ id: columnId,
478
+ field: columnId,
479
+ name: '',
480
+ alwaysRenderColumn: this._addonOptions?.alwaysRenderColumn,
481
+ cssClass: this._addonOptions.cssClass || '',
482
+ excludeFromExport: true,
483
+ excludeFromColumnPicker: true,
484
+ excludeFromGridMenu: true,
485
+ excludeFromQuery: true,
486
+ excludeFromHeaderMenu: true,
487
+ formatter: this.detailSelectionFormatter.bind(this),
488
+ reorderable: this._addonOptions.reorderable,
489
+ resizable: false,
490
+ sortable: false,
491
+ toolTip: this._addonOptions.toolTip,
492
+ width: this._addonOptions.width,
493
+ };
494
+ }
495
+
496
+ /** return the currently expanded rows */
497
+ getExpandedRowIds(): Array<number | string> {
498
+ return Array.from(this._expandedRowIds);
499
+ }
500
+
501
+ /** return the rows that are out of the viewport */
502
+ getOutOfViewportRows(): Array<number | string> {
503
+ return Array.from(this._rowIdsOutOfViewport);
504
+ }
505
+
506
+ /** Takes in the item we are filtering and if it is an expanded row returns it's parents row to filter on */
507
+ getFilterItem(item: any): any {
508
+ if (item[`${this._keyPrefix}isPadding`] && item[`${this._keyPrefix}parent`]) {
509
+ item = item[`${this._keyPrefix}parent`];
510
+ }
511
+ return item;
512
+ }
513
+
514
+ /** Resize the Row Detail View */
515
+ resizeDetailView(item: any): void {
516
+ if (!item) {
517
+ return;
518
+ }
519
+
520
+ const cellItem = document.querySelector<HTMLDivElement>(`.${this.gridUid} .row-detail-${item[this._dataViewIdProperty]}`);
521
+
522
+ if (!cellItem) {
523
+ return;
524
+ }
525
+
526
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
527
+ this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
528
+ }
529
+
530
+ const rowHeight = this.gridOptions.rowHeight as number; // height of a row
531
+ const lineHeight = 13; // we know cuz we wrote the custom css init ;)
532
+
533
+ // Measure the content independently of the panel's current height so it can also shrink.
534
+ const scrollTop = cellItem.scrollTop;
535
+ cellItem.style.height = 'auto';
536
+
537
+ const itemHeight = cellItem.scrollHeight;
538
+
539
+ // Now work out how many rows
540
+ const rowCount = Math.ceil(itemHeight / rowHeight);
541
+
542
+ item[`${this._keyPrefix}sizePadding`] = Math.ceil((rowCount * 2 * lineHeight) / rowHeight);
543
+ item[`${this._keyPrefix}height`] = itemHeight;
544
+
545
+ let outterHeight = item[`${this._keyPrefix}sizePadding`] * rowHeight;
546
+ if (this._addonOptions.maxRows !== undefined && item[`${this._keyPrefix}sizePadding`] > this._addonOptions.maxRows) {
547
+ outterHeight = this._addonOptions.maxRows! * rowHeight;
548
+ item[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows;
549
+ }
550
+
551
+ // If the padding is now more than the original minRowBuff we need to increase it
552
+ if (this.gridOptions.minRowBuffer! < item[`${this._keyPrefix}sizePadding`]) {
553
+ // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
554
+ this.gridOptions.minRowBuffer = item[`${this._keyPrefix}sizePadding`] + 3;
555
+ }
556
+
557
+ cellItem.style.height = `${outterHeight}px`;
558
+ cellItem.scrollTop = scrollTop;
559
+
560
+ const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]) as number;
561
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
562
+ this.dataView.insertItem(idxParent + idx, this.getPaddingItem(item, idx));
563
+ }
564
+
565
+ // Lastly save the updated state
566
+ this.saveDetailView(item);
567
+ }
568
+
569
+ /**
570
+ * Hide any expanded Row Detail whose parent row is no longer part of the (filtered) DataView, without collapsing it,
571
+ * so that it reopens automatically once the parent row is back in the filtered/sorted dataset.
572
+ * Runs synchronously off DataView events, ahead of any framework-specific "onFilterChanged" redraw, to avoid removing
573
+ * a Row Detail container while a framework adapter is in the middle of (re)rendering into it.
574
+ */
575
+ protected hideRowDetailsForFilteredOutParents(): void {
576
+ this._expandedRowIds.forEach((itemId) => {
577
+ if (this._renderedViewportRowIds.has(itemId) && this.dataView.getRowById(itemId) === undefined) {
578
+ this.notifyViewportChange(this.dataView.getItemById(itemId) ?? {}, 'remove', true);
579
+ }
580
+ });
581
+ }
582
+
583
+ /**
584
+ * (re)calculate/sync row detail views that are out of range of the viewport and trigger events (when enabled)
585
+ * @param {Boolean} [triggerEvent] - should trigger notify event which will re-render the detail view
586
+ * @param {Number} [delay] - optional delay to execute the calculation of out of range views
587
+ * @param {Boolean} [renderPanels] - skip the extra positioning pass when scrolling relies on the grid render
588
+ */
589
+ recalculateOutOfRangeViews(triggerEvent = true, delay?: number, renderPanels = true): void {
590
+ clearTimeout(this._backViewportTimer);
591
+
592
+ const calculateFn = () =>
593
+ this._expandedRowIds.forEach((itemId) => {
594
+ const item = this.dataView.getItemById(itemId) ?? {};
595
+ const rowIdx = this.dataView.getRowById(itemId) as number | undefined;
596
+
597
+ // the parent row no longer exists in the (filtered) DataView, hide its Row Detail without collapsing it
598
+ // so that it can reopen automatically once the parent row is back in the filtered dataset
599
+ if (rowIdx === undefined) {
600
+ if (this._renderedViewportRowIds.has(itemId)) {
601
+ this.notifyViewportChange(item, 'remove', triggerEvent);
602
+ }
603
+ return;
604
+ }
605
+
606
+ const cachedRows = Object.keys(this._grid.getRowCache()).map(Number);
607
+
608
+ const visible = this._grid.getRenderedRange();
609
+ // the grid hasn't rendered a viewport yet (e.g. very first data load), nothing to add/remove based on scroll position
610
+ if (!visible) {
611
+ return;
612
+ }
613
+ const rowDetailCount = item[`${this._keyPrefix}sizePadding`] || 0;
614
+ this._visibleRenderedCell = { startRow: visible.top, endRow: visible.bottom };
615
+ let { startRow, endRow } = this._visibleRenderedCell;
616
+ if (rowIdx >= startRow && rowIdx <= endRow) {
617
+ const rowSum = rowIdx + (this.gridOptions.rowDetailView?.panelRows ?? 0);
618
+ if (rowSum > endRow) {
619
+ endRow = rowSum;
620
+ }
621
+ }
622
+ const rdEndRow = rowIdx + rowDetailCount;
623
+ // extend startRow to include row header when any part of the detail panel rows is visible
624
+ if (startRow > rowIdx && rdEndRow >= this._visibleRenderedCell!.startRow) {
625
+ startRow = rowIdx;
626
+ }
627
+ this._visibleRenderedCell = { startRow, endRow };
628
+
629
+ if (
630
+ !this._renderedViewportRowIds.has(itemId) &&
631
+ this._visibleRenderedCell &&
632
+ rowIdx >= this._visibleRenderedCell.startRow &&
633
+ rowIdx <= this._visibleRenderedCell.endRow &&
634
+ cachedRows.includes(rowIdx)
635
+ ) {
636
+ this._disposedRows.delete(rowIdx);
637
+ this.notifyViewportChange(item, 'add', triggerEvent);
638
+ } else if (
639
+ (this._disposedRows.has(rowIdx) && !cachedRows.includes(rowIdx)) ||
640
+ (this._renderedViewportRowIds.has(itemId) &&
641
+ this._visibleRenderedCell &&
642
+ (rowIdx < this._visibleRenderedCell.startRow || rowIdx > this._visibleRenderedCell.endRow))
643
+ ) {
644
+ this.notifyViewportChange(item, 'remove', triggerEvent);
645
+ }
646
+ });
647
+
648
+ if (delay !== undefined) {
649
+ this._backViewportTimer = setTimeout(calculateFn, delay);
650
+ } else {
651
+ calculateFn();
652
+ }
653
+
654
+ if (renderPanels) {
655
+ this.renderOverlayPanels();
656
+ }
657
+ }
658
+
659
+ // --
660
+ // protected functions
661
+ // ------------------
662
+
663
+ /** Framework adapters can synchronously dispose their preload/current view before SlickGrid replaces its template. */
664
+ protected beforeDetailViewRender(_item: any): void {}
665
+
666
+ /** Framework adapters can mount their view after SlickGrid rendered. */
667
+ protected renderDetailView(_item: any): void | Promise<void> {}
668
+
669
+ /** Complete pending async responses only after SlickGrid has finished its actual render cycle. */
670
+ protected flushPendingAsyncEndUpdates(): void {
671
+ for (const [itemId, { eventData, item, isDataViewUpdated }] of this._pendingAsyncEndUpdates) {
672
+ if (!isDataViewUpdated) {
673
+ continue;
674
+ }
675
+ this._pendingAsyncEndUpdates.delete(itemId);
676
+ this._renderedIds.add(itemId);
677
+ this.refreshOverlayPanel(item);
678
+
679
+ const renderResult = this.renderDetailView(item);
680
+ const notifyAsyncEndUpdate = () =>
681
+ this.onAsyncEndUpdate.notify(
682
+ {
683
+ grid: this._grid,
684
+ item,
685
+ },
686
+ eventData,
687
+ this
688
+ );
689
+
690
+ if (renderResult instanceof Promise) {
691
+ void renderResult.then(notifyAsyncEndUpdate);
692
+ } else {
693
+ notifyAsyncEndUpdate();
694
+ }
695
+ }
696
+ }
697
+
698
+ /** Render all expanded panels in sibling overlay layers of their grid canvases. */
699
+ protected renderOverlayPanels(): void {
700
+ if (!this._grid) {
701
+ return;
702
+ }
703
+
704
+ this._expandedRowIds.forEach((itemId) => {
705
+ if (!this._renderedViewportRowIds.has(itemId)) {
706
+ return;
707
+ }
708
+
709
+ const item = this.dataView.getItemById(itemId);
710
+ const row = this.dataView.getRowById(itemId);
711
+ if (!item || row === undefined) {
712
+ return;
713
+ }
714
+
715
+ const columnIdx = this._grid.getColumnIndex(String(this._addonOptions.columnId ?? this._defaults.columnId));
716
+ const viewport = this._grid.getViewportNode(columnIdx, row);
717
+ const canvas = viewport?.querySelector<HTMLDivElement>('.grid-canvas');
718
+ if (!canvas) {
719
+ return;
720
+ }
721
+
722
+ let panel = this._overlayPanels.get(itemId);
723
+ const wasPanelCreated = !panel;
724
+ if (!panel) {
725
+ panel = this.createDetailViewElement(item, row);
726
+ this._overlayPanels.set(itemId, panel);
727
+ }
728
+
729
+ if (panel.parentElement !== canvas) {
730
+ canvas.appendChild(panel);
731
+ }
732
+ this.updateOverlayPanelPosition(panel, item, row);
733
+
734
+ // A scroll-back can finish after the grid has rendered its rows. Notify framework
735
+ // adapters only after the overlay container exists so they can safely remount views.
736
+ if (wasPanelCreated && this._rowIdsOutOfViewport.has(itemId)) {
737
+ this.notifyBackToViewportWhenDomExist(item);
738
+ }
739
+ });
740
+ }
741
+
742
+ /** Remove all mounted and detached detail panels. */
743
+ protected disposeOverlayPanels(): void {
744
+ this._overlayPanels.forEach((panel) => panel.remove());
745
+ this._overlayPanels.clear();
746
+ }
747
+
748
+ /** Remove one panel without disturbing other expanded Row Details. */
749
+ protected removeOverlayPanel(itemId: number | string): void {
750
+ const panel = this._overlayPanels.get(itemId);
751
+ panel?.remove();
752
+ this._overlayPanels.delete(itemId);
753
+ }
754
+
755
+ /** Replace a framework-owned overlay panel after its preload component is unmounted. */
756
+ protected refreshOverlayPanel(item: any): void {
757
+ if (!item) {
758
+ return;
759
+ }
760
+
761
+ const itemId = item[this._dataViewIdProperty];
762
+ const panel = this._overlayPanels.get(itemId);
763
+ if (!panel) {
764
+ this.renderOverlayPanels();
765
+ return;
766
+ }
767
+
768
+ // Keep the outer panel node stable while a framework adapter transitions from
769
+ // its preload component to the real detail component. Replacing the panel
770
+ // itself can detach a React/Vue/Angular tree in the middle of its commit.
771
+ this.renderDetailContent(panel, item);
772
+
773
+ const row = this.dataView.getRowById(itemId);
774
+ if (row !== undefined) {
775
+ this.updateOverlayPanelPosition(panel, item, row);
776
+ }
777
+ }
778
+
779
+ /** Keep an existing overlay aligned after row heights or data-view positions change. */
780
+ protected updateOverlayPanelPosition(panel: HTMLDivElement, item: any, row: number): void {
781
+ const top = this.getDetailPanelTopOffset(row);
782
+ let outterHeight = 0;
783
+ for (let offset = 1; offset <= (item[`${this._keyPrefix}sizePadding`] || 0); offset++) {
784
+ outterHeight += this._grid.getRowHeight(row + offset);
785
+ }
786
+ panel.style.top = `${top}px`;
787
+ panel.style.height = `${outterHeight}px`;
788
+ const canvas = panel.parentElement;
789
+ if (canvas) {
790
+ const viewport = canvas.parentElement!;
791
+ panel.style.transform = `translateX(${viewport.getBoundingClientRect().left - canvas.getBoundingClientRect().left}px)`;
792
+ panel.style.width = `${viewport.clientWidth}px`;
793
+ }
794
+ }
795
+
796
+ /** Follow the reserved padding rows even when the parent is docked elsewhere. */
797
+ protected getDetailPanelTopOffset(row: number): number {
798
+ const columnIdx = this._grid.getColumnIndex(String(this._addonOptions.columnId ?? this._defaults.columnId)) ?? 0;
799
+ return this._grid.getCellNodeBox(row + 1, columnIdx)?.top ?? this._grid.getRowTop(row + 1);
800
+ }
801
+
802
+ /** Render or replace the detail content inside a panel container. */
803
+ protected renderDetailContent(container: HTMLElement, item: any): void {
804
+ const detailContent = item[`${this._keyPrefix}detailContent`];
805
+ if (detailContent instanceof HTMLElement) {
806
+ container.replaceChildren(detailContent);
807
+ } else {
808
+ container.innerHTML = this._grid.sanitizeHtmlString(detailContent);
809
+ }
810
+ }
811
+
812
+ /** Create the Row Detail panel DOM without deciding where it is mounted. */
813
+ protected createDetailViewElement(dataContext: any, _row: number): HTMLDivElement {
814
+ const cellDetailContainerElm = createDomElement('div', {
815
+ className: `slick-row-detail-container row-detail-${dataContext[this._dataViewIdProperty]}`,
816
+ style: {
817
+ boxSizing: 'border-box',
818
+ pointerEvents: 'auto',
819
+ },
820
+ });
821
+ this.renderDetailContent(cellDetailContainerElm, dataContext);
822
+ return cellDetailContainerElm;
823
+ }
824
+
825
+ /**
826
+ * create the row detail ctr node. this belongs to the dev & can be custom-styled as per
827
+ * @param {Object} item
828
+ */
829
+ protected applyTemplateNewLineHeight(item: any): void {
830
+ // the height is calculated by the template row count (how many line of items does the template view have)
831
+ const rowCount = this._addonOptions.panelRows;
832
+
833
+ // calculate padding requirements based on detail-content..
834
+ // ie. worst-case: create an invisible dom node now & find it's height.
835
+ const lineHeight = 13; // we know cuz we wrote the custom css init ;)
836
+ item[`${this._keyPrefix}sizePadding`] = Math.ceil((rowCount * 2 * lineHeight) / this.gridOptions.rowHeight!);
837
+ if (this._addonOptions.maxRows !== undefined) {
838
+ item[`${this._keyPrefix}sizePadding`] = Math.min(item[`${this._keyPrefix}sizePadding`], this._addonOptions.maxRows);
839
+ }
840
+ item[`${this._keyPrefix}height`] = item[`${this._keyPrefix}sizePadding`] * this.gridOptions.rowHeight!;
841
+ const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]);
842
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
843
+ this.dataView.insertItem((idxParent || 0) + idx, this.getPaddingItem(item, idx));
844
+ }
845
+ }
846
+
847
+ protected notifyViewportChange(item: any, action: 'add' | 'remove', triggerEvent = true): void {
848
+ if (item) {
849
+ const itemId = item[this._dataViewIdProperty];
850
+ if (action === 'add') {
851
+ this._renderedViewportRowIds.add(itemId);
852
+ triggerEvent && this.notifyBackToViewportWhenDomExist(item);
853
+ } else if (action === 'remove') {
854
+ this._renderedViewportRowIds.delete(itemId);
855
+ this._pendingBackToViewportRows.delete(itemId);
856
+ if (this._addonOptions.keepComponentAlive) {
857
+ this._overlayPanels.get(itemId)?.remove();
858
+ } else {
859
+ this.removeOverlayPanel(itemId);
860
+ }
861
+ triggerEvent && this.notifyOutOfViewport(item);
862
+ }
863
+ }
864
+ }
865
+
866
+ protected checkExpandableOverride(row: number, dataContext: any, grid: SlickGrid): boolean {
867
+ if (dataContext?.[`${this._keyPrefix}isPadding`]) {
868
+ return false;
869
+ }
870
+ if (typeof this._expandableOverride === 'function') {
871
+ return this._expandableOverride(row, dataContext, grid);
872
+ }
873
+ return true;
874
+ }
875
+
876
+ /** Get the Row Detail padding (which are the rows dedicated to the detail panel) */
877
+ protected getPaddingItem(parent: any, offset: any): any {
878
+ const item: any = {};
879
+ const setItemProperty = (property: string, value: any) => {
880
+ Object.defineProperty(item, property, { configurable: true, enumerable: true, writable: true, value });
881
+ };
882
+
883
+ // to make it work with Grouping,
884
+ // copy the parent's columns field values so that padding rows can follow the parent's group and be filtered/sorted with it
885
+ this._grid.getColumns().forEach(({ field }) => {
886
+ setItemProperty(field, parent[field]);
887
+ });
888
+
889
+ setItemProperty(this._dataViewIdProperty, `${parent[this._dataViewIdProperty]}.${offset}`);
890
+
891
+ // additional hidden padding metadata fields
892
+ setItemProperty(`${this._keyPrefix}collapsed`, true);
893
+ setItemProperty(`${this._keyPrefix}isPadding`, true);
894
+ setItemProperty(`${this._keyPrefix}parent`, parent);
895
+ setItemProperty(`${this._keyPrefix}offset`, offset);
896
+
897
+ return item;
898
+ }
899
+
900
+ /** The Formatter of the toggling icon of the Row Detail */
901
+ protected detailSelectionFormatter(
902
+ row: number,
903
+ _cell: number,
904
+ _val: any,
905
+ _colDef: Column,
906
+ dataContext: any,
907
+ grid: SlickGrid
908
+ ): FormatterResultWithHtml | HTMLElement | '' {
909
+ if (!this.checkExpandableOverride(row, dataContext, grid)) {
910
+ return '';
911
+ }
912
+
913
+ if (dataContext[`${this._keyPrefix}collapsed`] === undefined) {
914
+ dataContext[`${this._keyPrefix}collapsed`] = true;
915
+ dataContext[`${this._keyPrefix}sizePadding`] = 0; // the required number of pading rows
916
+ dataContext[`${this._keyPrefix}height`] = 0; // the actual height in pixels of the detail field
917
+ dataContext[`${this._keyPrefix}isPadding`] = false;
918
+ dataContext[`${this._keyPrefix}parent`] = undefined;
919
+ dataContext[`${this._keyPrefix}offset`] = 0;
920
+ }
921
+
922
+ if (dataContext[`${this._keyPrefix}collapsed`]) {
923
+ let collapsedClasses = `sgi ${this._addonOptions.cssClass || ''} expand `;
924
+ if (this._addonOptions.collapsedClass) {
925
+ collapsedClasses += this._addonOptions.collapsedClass;
926
+ }
927
+ return createDomElement('div', { className: classNameToList(collapsedClasses).join(' '), ariaExpanded: 'false' });
928
+ }
929
+
930
+ let expandedClasses = `sgi ${this._addonOptions.cssClass || ''} collapse `;
931
+ if (this._addonOptions.expandedClass) {
932
+ expandedClasses += this._addonOptions.expandedClass;
933
+ }
934
+
935
+ return {
936
+ html: createDomElement('div', { className: classNameToList(expandedClasses).join(' '), ariaExpanded: 'true' }),
937
+ };
938
+ }
939
+
940
+ /** When row is getting toggled, we will handle the action of collapsing/expanding */
941
+ protected handleAccordionShowHide(item: any): void {
942
+ if (item) {
943
+ const itemId = item[this._dataViewIdProperty];
944
+ if (!item[`${this._keyPrefix}collapsed`]) {
945
+ this.collapseDetailView(itemId);
946
+ } else {
947
+ this.expandDetailView(itemId);
948
+ }
949
+ }
950
+ }
951
+
952
+ /** Handle keyboard a11y for row detail toggle: Space, ArrowRight (expand), ArrowLeft (collapse). */
953
+ protected handleKeyDown(e: SlickEventData): void {
954
+ const key = e.key;
955
+ const activeCell = this._grid.getActiveCell();
956
+ const isSupportedKey = key === ' ' || key === 'ArrowRight' || key === 'ArrowLeft';
957
+ if (isSupportedKey && activeCell && !this._grid.getEditorLock()?.isActive()) {
958
+ const dataContext = this._grid.getDataItem(activeCell.row);
959
+ if (this.checkExpandableOverride(activeCell.row, dataContext, this._grid)) {
960
+ const column = this._grid.getColumnByIdx(activeCell.cell);
961
+ const isTogglerCell = this._addonOptions.useRowClick || column?.id === this._addonOptions.columnId;
962
+ const isCollapsed = !!dataContext[`${this._keyPrefix}collapsed`];
963
+ const isValidDirection = !(key === 'ArrowRight' && !isCollapsed) && !(key === 'ArrowLeft' && isCollapsed);
964
+
965
+ if (isTogglerCell && isValidDirection) {
966
+ e.preventDefault();
967
+ e.stopImmediatePropagation();
968
+ this.toggleRowSelection(activeCell.row, dataContext);
969
+ }
970
+ }
971
+ }
972
+ }
973
+
974
+ /** Handle mouse click event */
975
+ protected handleClick(e: SlickEventData, args: { row: number; cell: number }): void {
976
+ const dataContext = this._grid.getDataItem(args.row);
977
+
978
+ if (this.checkExpandableOverride(args.row, dataContext, this._grid)) {
979
+ // clicking on a row select checkbox
980
+ const column = this._grid.getColumnByIdx(args.cell);
981
+ // prettier-ignore
982
+ if (this._addonOptions.useRowClick || (column && column.id === this._addonOptions.columnId && e.target!.classList.contains(this._addonOptions.cssClass || ''))) {
983
+ // if editing, try to commit
984
+ if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) {
985
+ e.preventDefault();
986
+ e.stopImmediatePropagation();
987
+ return;
988
+ }
989
+
990
+ // trigger an event before toggling
991
+ // user could cancel the Row Detail opening when event is returning false
992
+ if (
993
+ this.onBeforeRowDetailToggle
994
+ .notify({ grid: this._grid, item: dataContext }, e, this, true) // last argument is to ignore click event might return false from Row Selection canCellBeActive() validation
995
+ .getReturnValue() === false
996
+ ) {
997
+ return;
998
+ }
999
+
1000
+ // tag any row details that will need to be re-rendered after the row detail is toggled.
1001
+ // for example if row(2) is open and we open row(1) then row(2) needs to be re-rendered,
1002
+ // if however row(1) is open and we open row(2) then there is nothing to re-render
1003
+ const toReRenderItems: any[] = [];
1004
+ const visible = this._grid.getRenderedRange();
1005
+ this._expandedRowIds.forEach((itemId) => {
1006
+ const row = this.dataView.getRowById(itemId);
1007
+ if (row !== undefined && row > args.row && row >= visible.top && row <= visible.bottom) {
1008
+ const item = this.dataView.getItemById(itemId) ?? {};
1009
+ toReRenderItems.push(item);
1010
+ this.notifyBeforeOutOfViewport(item);
1011
+ this.notifyOutOfViewport(item);
1012
+ }
1013
+ });
1014
+
1015
+ this.toggleRowSelection(args.row, dataContext);
1016
+
1017
+ // trigger an event after toggling
1018
+ this.onAfterRowDetailToggle.notify(
1019
+ {
1020
+ grid: this._grid,
1021
+ item: dataContext,
1022
+ expandedRows: Array.from(this._expandedRowIds).map((id) => this.dataView.getItemById(id)),
1023
+ },
1024
+ e,
1025
+ this
1026
+ );
1027
+
1028
+ // re-render the row details that were tagged as
1029
+ toReRenderItems.forEach(item => this.notifyViewportChange(item, 'add', true));
1030
+
1031
+ e.stopPropagation();
1032
+ e.stopImmediatePropagation();
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ /** Triggered when a one or all data Groups are collapsed */
1038
+ protected handleGroupCollapsed(groupingKey: number | string | null): void {
1039
+ if (groupingKey) {
1040
+ this.getGroupItemIds(groupingKey).forEach((rowId) => {
1041
+ if (this._renderedViewportRowIds.has(rowId)) {
1042
+ const item = this.dataView.getItemById(rowId);
1043
+ this.notifyBeforeOutOfViewport(item);
1044
+ this.notifyViewportChange(item, 'remove');
1045
+ }
1046
+ });
1047
+ } else {
1048
+ // no grouping key means all groups are being collapsed
1049
+ this._expandedRowIds.forEach((rowId) => {
1050
+ this._renderedCollapsedGroupIds.add(rowId);
1051
+ const item = this.dataView.getItemById(rowId);
1052
+ this.notifyBeforeOutOfViewport(item);
1053
+ this.notifyViewportChange(item, 'remove');
1054
+ });
1055
+ this._expandedRowIds.clear();
1056
+ }
1057
+ }
1058
+
1059
+ /** Triggered when a one or all data Groups are expanded */
1060
+ protected handleGroupExpanded(groupingKey: number | string | null): void {
1061
+ if (groupingKey) {
1062
+ this.getGroupItemIds(groupingKey).forEach((rowId) => {
1063
+ if (this._expandedRowIds.has(rowId)) {
1064
+ this.notifyViewportChange(this.dataView.getItemById(rowId), 'add');
1065
+ }
1066
+ });
1067
+ } else {
1068
+ // no grouping key means all groups are being expanded
1069
+ this._renderedCollapsedGroupIds.forEach((rowId) => {
1070
+ this._expandedRowIds.add(rowId);
1071
+ });
1072
+ this.recalculateOutOfRangeViews(true, 0);
1073
+ this._renderedCollapsedGroupIds.clear();
1074
+ }
1075
+ }
1076
+
1077
+ protected handleRemoveRow(rowIndex: number): void {
1078
+ const item = this.dataView.getItemByIdx(rowIndex);
1079
+
1080
+ if (item) {
1081
+ const rowId = item[this.dataViewIdProperty];
1082
+
1083
+ if (this._expandedRowIds.has(rowId)) {
1084
+ this.notifyBeforeOutOfViewport(item, rowIndex);
1085
+ this._disposedRows.add(rowIndex);
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ /** Let framework adapters release views before a detail row leaves the rendered range. */
1091
+ protected notifyBeforeOutOfViewport(item: any, rowIndex?: number): void {
1092
+ if (!item) {
1093
+ return;
1094
+ }
1095
+
1096
+ const rowId = item[this._dataViewIdProperty];
1097
+
1098
+ this.onBeforeRowOutOfViewportRange.notify(
1099
+ {
1100
+ grid: this._grid,
1101
+ item,
1102
+ rowId,
1103
+ rowIndex: rowIndex ?? item.rowIndex ?? this.dataView.getRowById(rowId),
1104
+ expandedRows: Array.from(this._expandedRowIds).map((id) => this.dataView.getItemById(id)),
1105
+ rowIdsOutOfViewport: Array.from(this.syncOutOfViewportArray(rowId, true)),
1106
+ },
1107
+ null,
1108
+ this
1109
+ );
1110
+ }
1111
+
1112
+ /** Get the item IDs of the Row Details that are expanded and under a specific Group */
1113
+ protected getGroupItemIds(groupingKey: string | number): Array<number | string> {
1114
+ return (
1115
+ this.dataView
1116
+ .getItemsByGroupingKey(groupingKey)
1117
+ .filter((item) => !item[`${this._keyPrefix}isPadding`])
1118
+ .map((item) => item[this._dataViewIdProperty]) || []
1119
+ );
1120
+ }
1121
+
1122
+ protected notifyOutOfViewport(item: any): void {
1123
+ const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
1124
+ const rowId = item[this.dataViewIdProperty];
1125
+
1126
+ this._renderedIds.delete(rowId);
1127
+ this.onRowOutOfViewportRange.notify(
1128
+ {
1129
+ grid: this._grid,
1130
+ item,
1131
+ rowId,
1132
+ rowIndex,
1133
+ expandedRows: Array.from(this._expandedRowIds).map((id) => this.dataView.getItemById(id)),
1134
+ rowIdsOutOfViewport: Array.from(this.syncOutOfViewportArray(rowId, true)),
1135
+ },
1136
+ null,
1137
+ this
1138
+ );
1139
+ }
1140
+
1141
+ protected notifyBackToViewportWhenDomExist(item: any): void {
1142
+ const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
1143
+ const rowId = item[this.dataViewIdProperty];
1144
+
1145
+ this.renderOverlayPanels();
1146
+
1147
+ // make sure View Row DOM Element really exist before notifying that it's a row that is visible again
1148
+ if (document.querySelector(`.${this.gridUid} .row-detail-${item[this._dataViewIdProperty]}`)) {
1149
+ this._pendingBackToViewportRows.delete(rowId);
1150
+ this.onRowBackToViewportRange.notify(
1151
+ {
1152
+ grid: this._grid,
1153
+ item,
1154
+ rowId,
1155
+ rowIndex,
1156
+ expandedRows: Array.from(this._expandedRowIds).map((id) => this.dataView.getItemById(id)),
1157
+ rowIdsOutOfViewport: Array.from(this.syncOutOfViewportArray(rowId, false)),
1158
+ },
1159
+ null,
1160
+ this
1161
+ );
1162
+ } else {
1163
+ this._pendingBackToViewportRows.set(rowId, item);
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * keep any row detail that are shown on top of the row that we're opening
1169
+ * but invalidate any rows that are after the row that we're opening
1170
+ */
1171
+ protected reevaluateRenderedRowIds(item: any): void {
1172
+ // get current item row index
1173
+ const rowIdx = this.dataView.getRowById(item[this._dataViewIdProperty]) as number;
1174
+ this._renderedViewportRowIds.forEach((rid) => {
1175
+ const invRowIdx = this.dataView.getRowById(rid);
1176
+ if (invRowIdx !== undefined && invRowIdx > rowIdx) {
1177
+ this.notifyViewportChange(this.dataView.getItemById(rid), 'remove');
1178
+ }
1179
+ });
1180
+ }
1181
+
1182
+ protected syncOutOfViewportArray(rowId: number | string, isAdding: boolean): Set<string | number> {
1183
+ const hasRowId = this._rowIdsOutOfViewport.has(rowId);
1184
+
1185
+ if (isAdding && !hasRowId) {
1186
+ this._rowIdsOutOfViewport.add(rowId);
1187
+ } else if (!isAdding && hasRowId) {
1188
+ this._rowIdsOutOfViewport.delete(rowId);
1189
+ }
1190
+ return this._rowIdsOutOfViewport;
1191
+ }
1192
+
1193
+ protected toggleRowSelection(rowNumber: number, dataContext: any): void {
1194
+ if (this.checkExpandableOverride(rowNumber, dataContext, this._grid)) {
1195
+ this.dataView.beginUpdate();
1196
+ this.handleAccordionShowHide(dataContext);
1197
+ this.dataView.endUpdate();
1198
+ }
1199
+ }
1200
+ }