@slickgrid-universal/row-detail-view-plugin 5.0.0-beta.0 → 5.0.0-beta.1

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.
@@ -1,782 +1,782 @@
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
- OnRowBackToViewportRangeArgs,
10
- OnRowDetailAsyncEndUpdateArgs,
11
- OnRowDetailAsyncResponseArgs,
12
- OnRowOutOfViewportRangeArgs,
13
- PubSubService,
14
- RowDetailView,
15
- RowDetailViewOption,
16
- SlickGrid,
17
- SlickRowDetailView as UniversalRowDetailView,
18
- SlickDataView,
19
- SlickEventData,
20
- UsabilityOverrideFn,
21
- } from '@slickgrid-universal/common';
22
- import { classNameToList, extend } from '@slickgrid-universal/utils';
23
-
24
- /**
25
- * A plugin to add Row Detail Panel View (for example providing order detail info when clicking on the order row in the grid)
26
- * Original StackOverflow question & article making this possible (thanks to violet313)
27
- * https://stackoverflow.com/questions/10535164/can-slickgrids-row-height-be-dynamically-altered#29399927
28
- * http://violet313.org/slickgrids/#intro
29
- */
30
- export class SlickRowDetailView implements ExternalResource, UniversalRowDetailView {
31
- // --
32
- // public API
33
- pluginName = 'RowDetailView' as const;
34
-
35
- /** Fired when the async response finished */
36
- onAsyncEndUpdate = new SlickEvent<OnRowDetailAsyncEndUpdateArgs>('onAsyncEndUpdate');
37
-
38
- /** This event must be used with the "notify" by the end user once the Asynchronous Server call returns the item detail */
39
- onAsyncResponse = new SlickEvent<OnRowDetailAsyncResponseArgs>('onAsyncResponse');
40
-
41
- /** Fired after the row detail gets toggled */
42
- onAfterRowDetailToggle = new SlickEvent<OnAfterRowDetailToggleArgs>('onAfterRowDetailToggle');
43
-
44
- /** Fired before the row detail gets toggled */
45
- onBeforeRowDetailToggle = new SlickEvent<OnBeforeRowDetailToggleArgs>('onBeforeRowDetailToggle');
46
-
47
- /** Fired after the row detail gets toggled */
48
- onRowBackToViewportRange = new SlickEvent<OnRowBackToViewportRangeArgs>('onRowBackToViewportRange');
49
-
50
- /** Fired after a row becomes out of viewport range (when user can't see the row anymore) */
51
- onRowOutOfViewportRange = new SlickEvent<OnRowOutOfViewportRangeArgs>('onRowOutOfViewportRange');
52
-
53
- // --
54
- // protected props
55
- protected _addonOptions!: RowDetailView;
56
- protected _dataViewIdProperty = 'id';
57
- protected _eventHandler: SlickEventHandler;
58
- protected _expandableOverride: UsabilityOverrideFn | null = null;
59
- protected _expandedRows: any[] = [];
60
- protected _grid!: SlickGrid;
61
- protected _gridRowBuffer = 0;
62
- protected _gridUid = '';
63
- protected _keyPrefix = '';
64
- protected _lastRange: { bottom: number; top: number; } | null = null;
65
- protected _outsideRange = 5;
66
- protected _rowIdsOutOfViewport: Array<number | string> = [];
67
- protected _visibleRenderedCellCount = 0;
68
- protected _defaults = {
69
- alwaysRenderColumn: true,
70
- columnId: '_detail_selector',
71
- field: '_detail_selector',
72
- cssClass: 'detailView-toggle',
73
- collapseAllOnSort: true,
74
- collapsedClass: undefined,
75
- expandedClass: undefined,
76
- keyPrefix: '_',
77
- loadOnce: false,
78
- maxRows: undefined,
79
- reorderable: false,
80
- saveDetailViewOnScroll: true,
81
- singleRowExpand: false,
82
- useSimpleViewportCalc: false,
83
- toolTip: '',
84
- width: 30,
85
- } as unknown as RowDetailView;
86
-
87
- /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */
88
- constructor(protected readonly pubSubService: PubSubService) {
89
- this._eventHandler = new SlickEventHandler();
90
- }
91
-
92
- get addonOptions() {
93
- return this._addonOptions;
94
- }
95
-
96
- /** Getter of SlickGrid DataView object */
97
- get dataView(): SlickDataView {
98
- return this._grid?.getData<SlickDataView>();
99
- }
100
-
101
- get dataViewIdProperty(): string {
102
- return this._dataViewIdProperty;
103
- }
104
-
105
- get eventHandler() {
106
- return this._eventHandler;
107
- }
108
-
109
- /** Getter for the Grid Options pulled through the Grid Object */
110
- get gridOptions(): GridOption {
111
- return this._grid?.getOptions() || {};
112
- }
113
-
114
- get gridUid() {
115
- return this._gridUid || (this._grid?.getUID() || '');
116
- }
117
-
118
- set lastRange(range: { bottom: number; top: number; }) {
119
- this._lastRange = range;
120
- }
121
-
122
- set rowIdsOutOfViewport(rowIds: Array<string | number>) {
123
- this._rowIdsOutOfViewport = rowIds;
124
- }
125
-
126
- get visibleRenderedCellCount(): number {
127
- return this._visibleRenderedCellCount;
128
- }
129
-
130
- /**
131
- * Initialize the Export Service
132
- * @param _grid
133
- * @param _containerService
134
- */
135
- init(grid: SlickGrid) {
136
- this._grid = grid;
137
- if (!grid) {
138
- throw new Error('[Slickgrid-Universal] RowDetailView Plugin requires the Grid instance to be passed as argument to the "init()" method.');
139
- }
140
- this._grid = grid;
141
- this._gridUid = grid.getUID();
142
- if (!this._addonOptions) {
143
- this._addonOptions = extend(true, {}, this._defaults, this.gridOptions.rowDetailView) as RowDetailView;
144
- }
145
- this._keyPrefix = this._addonOptions?.keyPrefix || '_';
146
-
147
- // add PubSub instance to all SlickEvent
148
- SlickUtils.addSlickEventPubSubWhenDefined(this.pubSubService, this);
149
-
150
- // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
151
- this._gridRowBuffer = this.gridOptions.minRowBuffer || 0;
152
- this.gridOptions.minRowBuffer = this._addonOptions.panelRows + 3;
153
-
154
- this._eventHandler
155
- .subscribe(this._grid.onClick, this.handleClick.bind(this))
156
- .subscribe(this._grid.onBeforeEditCell, () => this.collapseAll())
157
- .subscribe(this._grid.onScroll, this.handleScroll.bind(this));
158
-
159
- // Sort will, by default, Collapse all of the open items (unless user implements his own onSort which deals with open row and padding)
160
- if (this._addonOptions.collapseAllOnSort) {
161
- // sort event can be triggered by column header click or from header menu
162
- this.pubSubService.subscribe('onSortChanged', () => this.collapseAll());
163
- this._expandedRows = [];
164
- this._rowIdsOutOfViewport = [];
165
- }
166
-
167
- this._eventHandler.subscribe(this.dataView.onRowCountChanged, () => {
168
- this._grid.updateRowCount();
169
- this._grid.render();
170
- });
171
-
172
- this._eventHandler.subscribe(this.dataView.onRowsChanged, (_e: SlickEventData, args: { rows: number[]; }) => {
173
- this._grid.invalidateRows(args.rows);
174
- this._grid.render();
175
- });
176
-
177
- // subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
178
- this._eventHandler.subscribe(this.onAsyncResponse, this.handleOnAsyncResponse.bind(this));
179
-
180
- // after data is set, let's get the DataView Id Property name used (defaults to "id")
181
- this._eventHandler.subscribe(this.dataView.onSetItemsCalled, () => {
182
- this._dataViewIdProperty = this.dataView?.getIdPropertyName() || 'id';
183
- });
184
-
185
- // if we use the alternative & simpler calculation of the out of viewport range
186
- // we will need to know how many rows are rendered on the screen and we need to wait for grid to be rendered
187
- // unfortunately there is no triggered event for knowing when grid is finished, so we use 250ms delay and it's typically more than enough
188
- if (this._addonOptions.useSimpleViewportCalc) {
189
- this._eventHandler.subscribe(this._grid.onRendered, (_e: SlickEventData, args: { endRow: number; startRow: number; }) => {
190
- if (args?.endRow) {
191
- this._visibleRenderedCellCount = args.endRow - args.startRow;
192
- }
193
- });
194
- }
195
- }
196
-
197
- /** Dispose of the Slick Row Detail View */
198
- dispose() {
199
- this._eventHandler?.unsubscribeAll();
200
- }
201
-
202
- create(columnDefinitions: Column[], gridOptions: GridOption): UniversalRowDetailView | null {
203
- if (!gridOptions.rowDetailView) {
204
- throw new Error('[Slickgrid-Universal] The Row Detail View requires options to be passed via the "rowDetailView" property of the Grid Options');
205
- }
206
-
207
- this._addonOptions = extend(true, {}, this._defaults, gridOptions.rowDetailView) as RowDetailView;
208
-
209
- // user could override the expandable icon logic from within the options or after instantiating the plugin
210
- if (typeof this._addonOptions.expandableOverride === 'function') {
211
- this.expandableOverride(this._addonOptions.expandableOverride);
212
- }
213
-
214
- if (Array.isArray(columnDefinitions) && gridOptions) {
215
- const newRowDetailViewColumn: Column = this.getColumnDefinition();
216
-
217
- // add new row detail column unless it was already added
218
- if (!columnDefinitions.some(col => col.id === newRowDetailViewColumn.id)) {
219
- const rowDetailColDef = Array.isArray(columnDefinitions) && columnDefinitions.find(col => col?.behavior === 'selectAndMove');
220
- const finalRowDetailViewColumn = rowDetailColDef ? rowDetailColDef : newRowDetailViewColumn;
221
-
222
- // column index position in the grid
223
- const columnPosition = gridOptions?.rowDetailView?.columnIndexPosition ?? 0;
224
- if (columnPosition > 0) {
225
- columnDefinitions.splice(columnPosition, 0, finalRowDetailViewColumn);
226
- } else {
227
- columnDefinitions.unshift(finalRowDetailViewColumn);
228
- }
229
-
230
- this.pubSubService.publish(`onPluginColumnsChanged`, {
231
- columns: columnDefinitions,
232
- pluginName: this.pluginName
233
- });
234
- }
235
- }
236
- return this as unknown as UniversalRowDetailView;
237
- }
238
-
239
- /** Get current plugin options */
240
- getOptions(): RowDetailViewOption {
241
- return this._addonOptions;
242
- }
243
-
244
- /** set or change some of the plugin options */
245
- setOptions(options: Partial<RowDetailViewOption>) {
246
- this._addonOptions = extend(true, {}, this._addonOptions, options) as RowDetailView;
247
- if (this._addonOptions?.singleRowExpand) {
248
- this.collapseAll();
249
- }
250
- }
251
-
252
- /** Collapse all of the open items */
253
- collapseAll() {
254
- this.dataView.beginUpdate();
255
- this._expandedRows.forEach(expandedRow => {
256
- this.collapseDetailView(expandedRow, true);
257
- });
258
- this.dataView.endUpdate();
259
- }
260
-
261
- /** Colapse an Item so it is not longer seen */
262
- collapseDetailView(item: any, isMultipleCollapsing = false) {
263
- if (!isMultipleCollapsing) {
264
- this.dataView.beginUpdate();
265
- }
266
- // Save the details on the collapse assuming onetime loading
267
- if (this._addonOptions.loadOnce) {
268
- this.saveDetailView(item);
269
- }
270
-
271
- item[`${this._keyPrefix}collapsed`] = true;
272
- for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
273
- this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
274
- }
275
- item[`${this._keyPrefix}sizePadding`] = 0;
276
- this.dataView.updateItem(item[this._dataViewIdProperty], item);
277
-
278
- // Remove the item from the expandedRows
279
- this._expandedRows = this._expandedRows.filter((expRow) => {
280
- return expRow[this._dataViewIdProperty] !== item[this._dataViewIdProperty];
281
- });
282
-
283
- if (!isMultipleCollapsing) {
284
- this.dataView.endUpdate();
285
- }
286
- }
287
-
288
- /** Expand a row given the dataview item that is to be expanded */
289
- expandDetailView(item: any) {
290
- if (this._addonOptions?.singleRowExpand) {
291
- this.collapseAll();
292
- }
293
-
294
- item[`${this._keyPrefix}collapsed`] = false;
295
- this._expandedRows.push(item);
296
-
297
- // In the case something went wrong loading it the first time such a scroll of screen before loaded
298
- if (!item[`${this._keyPrefix}detailContent`]) {
299
- item[`${this._keyPrefix}detailViewLoaded`] = false;
300
- }
301
-
302
- // display pre-loading template
303
- if (!item[`${this._keyPrefix}detailViewLoaded`] || this._addonOptions.loadOnce !== true) {
304
- item[`${this._keyPrefix}detailContent`] = this._addonOptions?.preTemplate?.(item);
305
- } else {
306
- this.onAsyncResponse.notify({
307
- item,
308
- itemDetail: item,
309
- detailView: item[`${this._keyPrefix}detailContent`],
310
- grid: this._grid
311
- });
312
- this.applyTemplateNewLineHeight(item);
313
- this.dataView.updateItem(item[this._dataViewIdProperty], item);
314
- return;
315
- }
316
-
317
- this.applyTemplateNewLineHeight(item);
318
- this.dataView.updateItem(item[this._dataViewIdProperty], item);
319
-
320
- // async server call
321
- this._addonOptions.process(item);
322
- }
323
-
324
- /** Saves the current state of the detail view */
325
- saveDetailView(item: any) {
326
- const view = document.querySelector(`.${this.gridUid} .innerDetailView_${item[this._dataViewIdProperty]}`);
327
- if (view) {
328
- const html = view.innerHTML;
329
- if (html !== undefined) {
330
- item[`${this._keyPrefix}detailContent`] = html;
331
- }
332
- }
333
- }
334
-
335
- /**
336
- * subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
337
- * the response has to be as "args.item" (or "args.itemDetail") with it's data back
338
- */
339
- handleOnAsyncResponse(e: SlickEventData, args: { item: any; itemDetail: any; detailView?: any; }) {
340
- if (!args || (!args.item && !args.itemDetail)) {
341
- console.error('SlickRowDetailView plugin requires the onAsyncResponse() to supply "args.item" property.');
342
- return;
343
- }
344
-
345
- // we accept item/itemDetail, just get the one which has data
346
- const itemDetail = args.item || args.itemDetail;
347
-
348
- // If we just want to load in a view directly we can use detailView property to do so
349
- itemDetail[`${this._keyPrefix}detailContent`] = args.detailView ?? this._addonOptions?.postTemplate?.(itemDetail);
350
- itemDetail[`${this._keyPrefix}detailViewLoaded`] = true;
351
- this.dataView.updateItem(itemDetail[this._dataViewIdProperty], itemDetail);
352
-
353
- // trigger an event once the post template is finished loading
354
- this.onAsyncEndUpdate.notify({
355
- grid: this._grid,
356
- item: itemDetail,
357
- itemDetail,
358
- }, e, this);
359
- }
360
-
361
- /**
362
- * TODO interface only has a GETTER not a SETTER..why?
363
- * Override the logic for showing (or not) the expand icon (use case example: only every 2nd row is expandable)
364
- * Method that user can pass to override the default behavior or making every row an expandable row.
365
- * In order word, user can choose which rows to be an available row detail (or not) by providing his own logic.
366
- * @param overrideFn: override function callback
367
- */
368
- expandableOverride(overrideFn: UsabilityOverrideFn) {
369
- this._expandableOverride = overrideFn;
370
- }
371
-
372
- getExpandableOverride(): UsabilityOverrideFn | null {
373
- return this._expandableOverride;
374
- }
375
-
376
- /** Get the Column Definition of the first column dedicated to toggling the Row Detail View */
377
- getColumnDefinition(): Column {
378
- const columnId = String(this._addonOptions?.columnId ?? this._defaults.columnId);
379
-
380
- return {
381
- id: columnId,
382
- field: columnId,
383
- name: '',
384
- alwaysRenderColumn: this._addonOptions?.alwaysRenderColumn,
385
- cssClass: this._addonOptions.cssClass || '',
386
- excludeFromExport: true,
387
- excludeFromColumnPicker: true,
388
- excludeFromGridMenu: true,
389
- excludeFromQuery: true,
390
- excludeFromHeaderMenu: true,
391
- formatter: this.detailSelectionFormatter.bind(this),
392
- reorderable: this._addonOptions.reorderable,
393
- resizable: false,
394
- sortable: false,
395
- toolTip: this._addonOptions.toolTip,
396
- width: this._addonOptions.width,
397
- };
398
- }
399
-
400
- /** return the currently expanded rows */
401
- getExpandedRows(): Array<number | string> {
402
- return this._expandedRows;
403
- }
404
-
405
- /** return the rows that are out of the viewport */
406
- getOutOfViewportRows(): Array<number | string> {
407
- return this._rowIdsOutOfViewport;
408
- }
409
-
410
- /** Takes in the item we are filtering and if it is an expanded row returns it's parents row to filter on */
411
- getFilterItem(item: any) {
412
- if (item[`${this._keyPrefix}isPadding`] && item[`${this._keyPrefix}parent`]) {
413
- item = item[`${this._keyPrefix}parent`];
414
- }
415
- return item;
416
- }
417
-
418
- /** Resize the Row Detail View */
419
- resizeDetailView(item: any) {
420
- if (!item) {
421
- return;
422
- }
423
-
424
- // Grad each of the DOM elements
425
- const mainContainer = document.querySelector<HTMLDivElement>(`.${this.gridUid} .detailViewContainer_${item[this._dataViewIdProperty]}`);
426
- const cellItem = document.querySelector<HTMLDivElement>(`.${this.gridUid} .cellDetailView_${item[this._dataViewIdProperty]}`);
427
- const inner = document.querySelector<HTMLDivElement>(`.${this.gridUid} .innerDetailView_${item[this._dataViewIdProperty]}`);
428
-
429
- if (!mainContainer || !cellItem || !inner) {
430
- return;
431
- }
432
-
433
- for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
434
- this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
435
- }
436
-
437
- const rowHeight = this.gridOptions.rowHeight as number; // height of a row
438
- const lineHeight = 13; // we know cuz we wrote the custom css init ;)
439
-
440
- // remove the height so we can calculate the height
441
- mainContainer.style.minHeight = '';
442
-
443
- // Get the scroll height for the main container so we know the actual size of the view
444
- const itemHeight = mainContainer.scrollHeight;
445
-
446
- // Now work out how many rows
447
- const rowCount = Math.ceil(itemHeight / rowHeight);
448
-
449
- item[`${this._keyPrefix}sizePadding`] = Math.ceil(((rowCount * 2) * lineHeight) / rowHeight);
450
- item[`${this._keyPrefix}height`] = itemHeight;
451
-
452
- let outterHeight = (item[`${this._keyPrefix}sizePadding`] * rowHeight);
453
- if (this._addonOptions.maxRows !== undefined && item[`${this._keyPrefix}sizePadding`] > this._addonOptions.maxRows) {
454
- outterHeight = this._addonOptions.maxRows! * rowHeight;
455
- item[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows;
456
- }
457
-
458
- // If the padding is now more than the original minRowBuff we need to increase it
459
- if (this.gridOptions.minRowBuffer! < item[`${this._keyPrefix}sizePadding`]) {
460
- // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
461
- this.gridOptions.minRowBuffer = item[`${this._keyPrefix}sizePadding`] + 3;
462
- }
463
-
464
- mainContainer.setAttribute('style', `min-height: ${item[this._keyPrefix + 'height']}px`);
465
- if (cellItem) {
466
- cellItem.setAttribute('style', `height: ${outterHeight}px; top: ${rowHeight}px`);
467
- }
468
-
469
- const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]) as number;
470
- for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
471
- this.dataView.insertItem(idxParent + idx, this.getPaddingItem(item, idx));
472
- }
473
-
474
- // Lastly save the updated state
475
- this.saveDetailView(item);
476
- }
477
-
478
- // --
479
- // protected functions
480
- // ------------------
481
-
482
- /**
483
- * create the detail ctr node. this belongs to the dev & can be custom-styled as per
484
- */
485
- protected applyTemplateNewLineHeight(item: any) {
486
- // the height is calculated by the template row count (how many line of items does the template view have)
487
- const rowCount = this._addonOptions.panelRows;
488
-
489
- // calculate padding requirements based on detail-content..
490
- // ie. worst-case: create an invisible dom node now & find it's height.
491
- const lineHeight = 13; // we know cuz we wrote the custom css init ;)
492
- item[`${this._keyPrefix}sizePadding`] = Math.ceil(((rowCount * 2) * lineHeight) / this.gridOptions.rowHeight!);
493
- item[`${this._keyPrefix}height`] = (item[`${this._keyPrefix}sizePadding`] * this.gridOptions.rowHeight!);
494
- const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]);
495
- for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
496
- this.dataView.insertItem((idxParent || 0) + idx, this.getPaddingItem(item, idx));
497
- }
498
- }
499
-
500
- protected calculateOutOfRangeViews() {
501
- if (this._grid) {
502
- let scrollDir: 'UP' | 'DOWN';
503
- const renderedRange = this._grid.getRenderedRange();
504
- // Only check if we have expanded rows
505
- if (this._expandedRows.length > 0) {
506
- // Assume scroll direction is down by default.
507
- scrollDir = 'DOWN';
508
- if (this._lastRange) {
509
- // Some scrolling isn't anything as the range is the same
510
- if (this._lastRange.top === renderedRange.top && this._lastRange.bottom === renderedRange.bottom) {
511
- return;
512
- }
513
-
514
- // If our new top is smaller we are scrolling up
515
- if (this._lastRange.top > renderedRange.top ||
516
- // Or we are at very top but our bottom is increasing
517
- (this._lastRange.top === 0 && renderedRange.top === 0 && (this._lastRange.bottom > renderedRange.bottom))) {
518
- scrollDir = 'UP';
519
- }
520
- }
521
- }
522
-
523
- this._expandedRows.forEach((row) => {
524
- const rowIndex = this.dataView.getRowById(row[this._dataViewIdProperty]) as number;
525
- const rowPadding = row[`${this._keyPrefix}sizePadding`];
526
- const isRowOutOfRange = this._rowIdsOutOfViewport.some(rowId => rowId === row[this._dataViewIdProperty]);
527
-
528
- if (scrollDir === 'UP') {
529
- // save the view when asked
530
- if (this._addonOptions.saveDetailViewOnScroll) {
531
- // If the bottom item within buffer range is an expanded row save it.
532
- if (rowIndex >= renderedRange.bottom - this._gridRowBuffer) {
533
- this.saveDetailView(row);
534
- }
535
- }
536
-
537
- // If the row expanded area is within the buffer notify that it is back in range
538
- if (isRowOutOfRange && ((rowIndex - this._outsideRange) < renderedRange.top) && (rowIndex >= renderedRange.top)) {
539
- this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
540
- } else if (!isRowOutOfRange && ((rowIndex + rowPadding) > renderedRange.bottom)) {
541
- // if our first expanded row is about to go off the bottom
542
- this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
543
- }
544
- } else if (scrollDir === 'DOWN') {
545
- // save the view when asked
546
- if (this._addonOptions.saveDetailViewOnScroll) {
547
- // If the top item within buffer range is an expanded row save it.
548
- if (rowIndex <= renderedRange.top + this._gridRowBuffer) {
549
- this.saveDetailView(row);
550
- }
551
- }
552
-
553
- // If row index is i higher than bottom with some added value (To ignore top rows off view) and is with view and was our of range
554
- if (isRowOutOfRange && ((rowIndex + rowPadding + this._outsideRange) > renderedRange.bottom) && (rowIndex < (rowIndex + rowPadding))) {
555
- this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
556
- } else if (!isRowOutOfRange && (rowIndex < renderedRange.top)) {
557
- // if our row is outside top of and the buffering zone but not in the array of outOfVisable range notify it
558
- this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
559
- }
560
- }
561
- });
562
- this._lastRange = renderedRange;
563
- }
564
- }
565
-
566
- protected calculateOutOfRangeViewsSimplerVersion() {
567
- if (this._grid) {
568
- const renderedRange = this._grid.getRenderedRange();
569
-
570
- this._expandedRows.forEach((row) => {
571
- const rowIndex = this.dataView.getRowById(row[this._dataViewIdProperty]) as number;
572
- const isOutOfVisibility = this.checkIsRowOutOfViewportRange(rowIndex, renderedRange);
573
- if (!isOutOfVisibility && this._rowIdsOutOfViewport.some(rowId => rowId === row[this._dataViewIdProperty])) {
574
- this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
575
- } else if (isOutOfVisibility) {
576
- this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
577
- }
578
- });
579
- }
580
- }
581
-
582
- protected checkExpandableOverride(row: number, dataContext: any, grid: SlickGrid) {
583
- if (typeof this._expandableOverride === 'function') {
584
- return this._expandableOverride(row, dataContext, grid);
585
- }
586
- return true;
587
- }
588
-
589
- protected checkIsRowOutOfViewportRange(rowIndex: number, renderedRange: any) {
590
- return (Math.abs(renderedRange.bottom - this._gridRowBuffer - rowIndex) > this._visibleRenderedCellCount * 2);
591
- }
592
-
593
- /** Get the Row Detail padding (which are the rows dedicated to the detail panel) */
594
- protected getPaddingItem(parent: any, offset: any) {
595
- const item: any = {};
596
-
597
- Object.keys(this.dataView).forEach(prop => {
598
- if (prop) {
599
- item[prop] = null;
600
- }
601
- });
602
- item[this._dataViewIdProperty] = `${parent[this._dataViewIdProperty]}.${offset}`;
603
-
604
- // additional hidden padding metadata fields
605
- item[`${this._keyPrefix}collapsed`] = true;
606
- item[`${this._keyPrefix}isPadding`] = true;
607
- item[`${this._keyPrefix}parent`] = parent;
608
- item[`${this._keyPrefix}offset`] = offset;
609
-
610
- return item;
611
- }
612
-
613
- /** The Formatter of the toggling icon of the Row Detail */
614
- protected detailSelectionFormatter(row: number, _cell: number, _val: any, _colDef: Column, dataContext: any, grid: SlickGrid): FormatterResultWithHtml | HTMLElement | '' {
615
- if (!this.checkExpandableOverride(row, dataContext, grid)) {
616
- return '';
617
- } else {
618
- if (dataContext[`${this._keyPrefix}collapsed`] === undefined) {
619
- dataContext[`${this._keyPrefix}collapsed`] = true;
620
- dataContext[`${this._keyPrefix}sizePadding`] = 0; // the required number of pading rows
621
- dataContext[`${this._keyPrefix}height`] = 0; // the actual height in pixels of the detail field
622
- dataContext[`${this._keyPrefix}isPadding`] = false;
623
- dataContext[`${this._keyPrefix}parent`] = undefined;
624
- dataContext[`${this._keyPrefix}offset`] = 0;
625
- }
626
-
627
- if (dataContext[`${this._keyPrefix}isPadding`]) {
628
- // render nothing
629
- } else if (dataContext[`${this._keyPrefix}collapsed`]) {
630
- let collapsedClasses = `${this._addonOptions.cssClass || ''} expand `;
631
- if (this._addonOptions.collapsedClass) {
632
- collapsedClasses += this._addonOptions.collapsedClass;
633
- }
634
- return createDomElement('div', { className: classNameToList(collapsedClasses).join(' ') });
635
- } else {
636
- const rowHeight = this.gridOptions.rowHeight || 0;
637
- let outterHeight = (dataContext[`${this._keyPrefix}sizePadding`] || 0) * this.gridOptions.rowHeight!;
638
-
639
- if (this._addonOptions.maxRows !== null && ((dataContext[`${this._keyPrefix}sizePadding`] || 0) > this._addonOptions.maxRows!)) {
640
- outterHeight = this._addonOptions.maxRows! * rowHeight!;
641
- dataContext[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows;
642
- }
643
-
644
- // sneaky extra </div> inserted here-----------------v
645
- let expandedClasses = `${this._addonOptions.cssClass || ''} collapse `;
646
- if (this._addonOptions.expandedClass) {
647
- expandedClasses += this._addonOptions.expandedClass;
648
- }
649
-
650
- // create the Row Detail div container that will be inserted AFTER the `.slick-cell`
651
- const cellDetailContainerElm = createDomElement('div', {
652
- className: `dynamic-cell-detail cellDetailView_${dataContext[this._dataViewIdProperty]}`,
653
- style: { height: `${outterHeight}px`, top: `${rowHeight}px` }
654
- });
655
- const innerContainerElm = createDomElement('div', { className: `detail-container detailViewContainer_${dataContext[this._dataViewIdProperty]}` });
656
- const innerDetailViewElm = createDomElement('div', { className: `innerDetailView_${dataContext[this._dataViewIdProperty]}` });
657
- if (dataContext[`${this._keyPrefix}detailContent`] instanceof HTMLElement) {
658
- innerDetailViewElm.appendChild(dataContext[`${this._keyPrefix}detailContent`]);
659
- } else {
660
- innerDetailViewElm.innerHTML = this._grid.sanitizeHtmlString(dataContext[`${this._keyPrefix}detailContent`]);
661
- }
662
-
663
- innerContainerElm.appendChild(innerDetailViewElm);
664
- cellDetailContainerElm.appendChild(innerContainerElm);
665
-
666
- const result: FormatterResultWithHtml = {
667
- html: createDomElement('div', { className: classNameToList(expandedClasses).join(' ') }),
668
- insertElementAfterTarget: cellDetailContainerElm,
669
- };
670
-
671
- return result;
672
- }
673
- }
674
- return '';
675
- }
676
-
677
- /** When row is getting toggled, we will handle the action of collapsing/expanding */
678
- protected handleAccordionShowHide(item: any) {
679
- if (item) {
680
- if (!item[`${this._keyPrefix}collapsed`]) {
681
- this.collapseDetailView(item);
682
- } else {
683
- this.expandDetailView(item);
684
- }
685
- }
686
- }
687
-
688
- /** Handle mouse click event */
689
- protected handleClick(e: SlickEventData, args: { row: number; cell: number; }) {
690
- const dataContext = this._grid.getDataItem(args.row);
691
-
692
- if (this.checkExpandableOverride(args.row, dataContext, this._grid)) {
693
- // clicking on a row select checkbox
694
- const columnDef = this._grid.getColumns()[args.cell];
695
- if (this._addonOptions.useRowClick || (columnDef.id === this._addonOptions.columnId && e.target!.classList.contains(this._addonOptions.cssClass || ''))) {
696
- // if editing, try to commit
697
- if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) {
698
- e.preventDefault();
699
- e.stopImmediatePropagation();
700
- return;
701
- }
702
-
703
- // trigger an event before toggling
704
- // user could cancel the Row Detail opening when event is returning false
705
- const ignorePrevEventDataValue = true; // click event might return false from Row Selection canCellBeActive() validation, we need to ignore that
706
- if (this.onBeforeRowDetailToggle.notify({ grid: this._grid, item: dataContext }, e, this, ignorePrevEventDataValue).getReturnValue() === false) {
707
- return;
708
- }
709
-
710
- this.toggleRowSelection(args.row, dataContext);
711
-
712
- // trigger an event after toggling
713
- this.onAfterRowDetailToggle.notify({
714
- grid: this._grid,
715
- item: dataContext,
716
- expandedRows: this._expandedRows,
717
- }, e, this);
718
-
719
- e.stopPropagation();
720
- e.stopImmediatePropagation();
721
- }
722
- }
723
- }
724
-
725
- protected handleScroll() {
726
- if (this._addonOptions.useSimpleViewportCalc) {
727
- this.calculateOutOfRangeViewsSimplerVersion();
728
- } else {
729
- this.calculateOutOfRangeViews();
730
- }
731
- }
732
-
733
- protected notifyOutOfViewport(item: any, rowId: number | string) {
734
- const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
735
-
736
- this.onRowOutOfViewportRange.notify({
737
- grid: this._grid,
738
- item,
739
- rowId,
740
- rowIndex,
741
- expandedRows: this._expandedRows,
742
- rowIdsOutOfViewport: this.syncOutOfViewportArray(rowId, true)
743
- }, null, this);
744
- }
745
-
746
- protected notifyBackToViewportWhenDomExist(item: any, rowId: number | string) {
747
- const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
748
-
749
- setTimeout(() => {
750
- // make sure View Row DOM Element really exist before notifying that it's a row that is visible again
751
- if (document.querySelector(`.${this.gridUid} .cellDetailView_${item[this._dataViewIdProperty]}`)) {
752
- this.onRowBackToViewportRange.notify({
753
- grid: this._grid,
754
- item,
755
- rowId,
756
- rowIndex,
757
- expandedRows: this._expandedRows,
758
- rowIdsOutOfViewport: this.syncOutOfViewportArray(rowId, false)
759
- }, null, this);
760
- }
761
- }, 100);
762
- }
763
-
764
- protected syncOutOfViewportArray(rowId: number | string, isAdding: boolean) {
765
- const arrayRowIndex = this._rowIdsOutOfViewport.findIndex(outOfViewportRowId => outOfViewportRowId === rowId);
766
-
767
- if (isAdding && arrayRowIndex < 0) {
768
- this._rowIdsOutOfViewport.push(rowId);
769
- } else if (!isAdding && arrayRowIndex >= 0) {
770
- this._rowIdsOutOfViewport.splice(arrayRowIndex, 1);
771
- }
772
- return this._rowIdsOutOfViewport;
773
- }
774
-
775
- protected toggleRowSelection(rowNumber: number, dataContext: any) {
776
- if (this.checkExpandableOverride(rowNumber, dataContext, this._grid)) {
777
- this.dataView.beginUpdate();
778
- this.handleAccordionShowHide(dataContext);
779
- this.dataView.endUpdate();
780
- }
781
- }
782
- }
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
+ OnRowBackToViewportRangeArgs,
10
+ OnRowDetailAsyncEndUpdateArgs,
11
+ OnRowDetailAsyncResponseArgs,
12
+ OnRowOutOfViewportRangeArgs,
13
+ PubSubService,
14
+ RowDetailView,
15
+ RowDetailViewOption,
16
+ SlickGrid,
17
+ SlickRowDetailView as UniversalRowDetailView,
18
+ SlickDataView,
19
+ SlickEventData,
20
+ UsabilityOverrideFn,
21
+ } from '@slickgrid-universal/common';
22
+ import { classNameToList, extend } from '@slickgrid-universal/utils';
23
+
24
+ /**
25
+ * A plugin to add Row Detail Panel View (for example providing order detail info when clicking on the order row in the grid)
26
+ * Original StackOverflow question & article making this possible (thanks to violet313)
27
+ * https://stackoverflow.com/questions/10535164/can-slickgrids-row-height-be-dynamically-altered#29399927
28
+ * http://violet313.org/slickgrids/#intro
29
+ */
30
+ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailView {
31
+ // --
32
+ // public API
33
+ pluginName = 'RowDetailView' as const;
34
+
35
+ /** Fired when the async response finished */
36
+ onAsyncEndUpdate = new SlickEvent<OnRowDetailAsyncEndUpdateArgs>('onAsyncEndUpdate');
37
+
38
+ /** This event must be used with the "notify" by the end user once the Asynchronous Server call returns the item detail */
39
+ onAsyncResponse = new SlickEvent<OnRowDetailAsyncResponseArgs>('onAsyncResponse');
40
+
41
+ /** Fired after the row detail gets toggled */
42
+ onAfterRowDetailToggle = new SlickEvent<OnAfterRowDetailToggleArgs>('onAfterRowDetailToggle');
43
+
44
+ /** Fired before the row detail gets toggled */
45
+ onBeforeRowDetailToggle = new SlickEvent<OnBeforeRowDetailToggleArgs>('onBeforeRowDetailToggle');
46
+
47
+ /** Fired after the row detail gets toggled */
48
+ onRowBackToViewportRange = new SlickEvent<OnRowBackToViewportRangeArgs>('onRowBackToViewportRange');
49
+
50
+ /** Fired after a row becomes out of viewport range (when user can't see the row anymore) */
51
+ onRowOutOfViewportRange = new SlickEvent<OnRowOutOfViewportRangeArgs>('onRowOutOfViewportRange');
52
+
53
+ // --
54
+ // protected props
55
+ protected _addonOptions!: RowDetailView;
56
+ protected _dataViewIdProperty = 'id';
57
+ protected _eventHandler: SlickEventHandler;
58
+ protected _expandableOverride: UsabilityOverrideFn | null = null;
59
+ protected _expandedRows: any[] = [];
60
+ protected _grid!: SlickGrid;
61
+ protected _gridRowBuffer = 0;
62
+ protected _gridUid = '';
63
+ protected _keyPrefix = '';
64
+ protected _lastRange: { bottom: number; top: number; } | null = null;
65
+ protected _outsideRange = 5;
66
+ protected _rowIdsOutOfViewport: Array<number | string> = [];
67
+ protected _visibleRenderedCellCount = 0;
68
+ protected _defaults = {
69
+ alwaysRenderColumn: true,
70
+ columnId: '_detail_selector',
71
+ field: '_detail_selector',
72
+ cssClass: 'detailView-toggle',
73
+ collapseAllOnSort: true,
74
+ collapsedClass: undefined,
75
+ expandedClass: undefined,
76
+ keyPrefix: '_',
77
+ loadOnce: false,
78
+ maxRows: undefined,
79
+ reorderable: false,
80
+ saveDetailViewOnScroll: true,
81
+ singleRowExpand: false,
82
+ useSimpleViewportCalc: false,
83
+ toolTip: '',
84
+ width: 30,
85
+ } as unknown as RowDetailView;
86
+
87
+ /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */
88
+ constructor(protected readonly pubSubService: PubSubService) {
89
+ this._eventHandler = new SlickEventHandler();
90
+ }
91
+
92
+ get addonOptions() {
93
+ return this._addonOptions;
94
+ }
95
+
96
+ /** Getter of SlickGrid DataView object */
97
+ get dataView(): SlickDataView {
98
+ return this._grid?.getData<SlickDataView>();
99
+ }
100
+
101
+ get dataViewIdProperty(): string {
102
+ return this._dataViewIdProperty;
103
+ }
104
+
105
+ get eventHandler() {
106
+ return this._eventHandler;
107
+ }
108
+
109
+ /** Getter for the Grid Options pulled through the Grid Object */
110
+ get gridOptions(): GridOption {
111
+ return this._grid?.getOptions() || {};
112
+ }
113
+
114
+ get gridUid() {
115
+ return this._gridUid || (this._grid?.getUID() || '');
116
+ }
117
+
118
+ set lastRange(range: { bottom: number; top: number; }) {
119
+ this._lastRange = range;
120
+ }
121
+
122
+ set rowIdsOutOfViewport(rowIds: Array<string | number>) {
123
+ this._rowIdsOutOfViewport = rowIds;
124
+ }
125
+
126
+ get visibleRenderedCellCount(): number {
127
+ return this._visibleRenderedCellCount;
128
+ }
129
+
130
+ /**
131
+ * Initialize the Export Service
132
+ * @param _grid
133
+ * @param _containerService
134
+ */
135
+ init(grid: SlickGrid) {
136
+ this._grid = grid;
137
+ if (!grid) {
138
+ throw new Error('[Slickgrid-Universal] RowDetailView Plugin requires the Grid instance to be passed as argument to the "init()" method.');
139
+ }
140
+ this._grid = grid;
141
+ this._gridUid = grid.getUID();
142
+ if (!this._addonOptions) {
143
+ this._addonOptions = extend(true, {}, this._defaults, this.gridOptions.rowDetailView) as RowDetailView;
144
+ }
145
+ this._keyPrefix = this._addonOptions?.keyPrefix || '_';
146
+
147
+ // add PubSub instance to all SlickEvent
148
+ SlickUtils.addSlickEventPubSubWhenDefined(this.pubSubService, this);
149
+
150
+ // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
151
+ this._gridRowBuffer = this.gridOptions.minRowBuffer || 0;
152
+ this.gridOptions.minRowBuffer = this._addonOptions.panelRows + 3;
153
+
154
+ this._eventHandler
155
+ .subscribe(this._grid.onClick, this.handleClick.bind(this))
156
+ .subscribe(this._grid.onBeforeEditCell, () => this.collapseAll())
157
+ .subscribe(this._grid.onScroll, this.handleScroll.bind(this));
158
+
159
+ // Sort will, by default, Collapse all of the open items (unless user implements his own onSort which deals with open row and padding)
160
+ if (this._addonOptions.collapseAllOnSort) {
161
+ // sort event can be triggered by column header click or from header menu
162
+ this.pubSubService.subscribe('onSortChanged', () => this.collapseAll());
163
+ this._expandedRows = [];
164
+ this._rowIdsOutOfViewport = [];
165
+ }
166
+
167
+ this._eventHandler.subscribe(this.dataView.onRowCountChanged, () => {
168
+ this._grid.updateRowCount();
169
+ this._grid.render();
170
+ });
171
+
172
+ this._eventHandler.subscribe(this.dataView.onRowsChanged, (_e: SlickEventData, args: { rows: number[]; }) => {
173
+ this._grid.invalidateRows(args.rows);
174
+ this._grid.render();
175
+ });
176
+
177
+ // subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
178
+ this._eventHandler.subscribe(this.onAsyncResponse, this.handleOnAsyncResponse.bind(this));
179
+
180
+ // after data is set, let's get the DataView Id Property name used (defaults to "id")
181
+ this._eventHandler.subscribe(this.dataView.onSetItemsCalled, () => {
182
+ this._dataViewIdProperty = this.dataView?.getIdPropertyName() || 'id';
183
+ });
184
+
185
+ // if we use the alternative & simpler calculation of the out of viewport range
186
+ // we will need to know how many rows are rendered on the screen and we need to wait for grid to be rendered
187
+ // unfortunately there is no triggered event for knowing when grid is finished, so we use 250ms delay and it's typically more than enough
188
+ if (this._addonOptions.useSimpleViewportCalc) {
189
+ this._eventHandler.subscribe(this._grid.onRendered, (_e: SlickEventData, args: { endRow: number; startRow: number; }) => {
190
+ if (args?.endRow) {
191
+ this._visibleRenderedCellCount = args.endRow - args.startRow;
192
+ }
193
+ });
194
+ }
195
+ }
196
+
197
+ /** Dispose of the Slick Row Detail View */
198
+ dispose() {
199
+ this._eventHandler?.unsubscribeAll();
200
+ }
201
+
202
+ create(columnDefinitions: Column[], gridOptions: GridOption): UniversalRowDetailView | null {
203
+ if (!gridOptions.rowDetailView) {
204
+ throw new Error('[Slickgrid-Universal] The Row Detail View requires options to be passed via the "rowDetailView" property of the Grid Options');
205
+ }
206
+
207
+ this._addonOptions = extend(true, {}, this._defaults, gridOptions.rowDetailView) as RowDetailView;
208
+
209
+ // user could override the expandable icon logic from within the options or after instantiating the plugin
210
+ if (typeof this._addonOptions.expandableOverride === 'function') {
211
+ this.expandableOverride(this._addonOptions.expandableOverride);
212
+ }
213
+
214
+ if (Array.isArray(columnDefinitions) && gridOptions) {
215
+ const newRowDetailViewColumn: Column = this.getColumnDefinition();
216
+
217
+ // add new row detail column unless it was already added
218
+ if (!columnDefinitions.some(col => col.id === newRowDetailViewColumn.id)) {
219
+ const rowDetailColDef = Array.isArray(columnDefinitions) && columnDefinitions.find(col => col?.behavior === 'selectAndMove');
220
+ const finalRowDetailViewColumn = rowDetailColDef ? rowDetailColDef : newRowDetailViewColumn;
221
+
222
+ // column index position in the grid
223
+ const columnPosition = gridOptions?.rowDetailView?.columnIndexPosition ?? 0;
224
+ if (columnPosition > 0) {
225
+ columnDefinitions.splice(columnPosition, 0, finalRowDetailViewColumn);
226
+ } else {
227
+ columnDefinitions.unshift(finalRowDetailViewColumn);
228
+ }
229
+
230
+ this.pubSubService.publish(`onPluginColumnsChanged`, {
231
+ columns: columnDefinitions,
232
+ pluginName: this.pluginName
233
+ });
234
+ }
235
+ }
236
+ return this as unknown as UniversalRowDetailView;
237
+ }
238
+
239
+ /** Get current plugin options */
240
+ getOptions(): RowDetailViewOption {
241
+ return this._addonOptions;
242
+ }
243
+
244
+ /** set or change some of the plugin options */
245
+ setOptions(options: Partial<RowDetailViewOption>) {
246
+ this._addonOptions = extend(true, {}, this._addonOptions, options) as RowDetailView;
247
+ if (this._addonOptions?.singleRowExpand) {
248
+ this.collapseAll();
249
+ }
250
+ }
251
+
252
+ /** Collapse all of the open items */
253
+ collapseAll() {
254
+ this.dataView.beginUpdate();
255
+ this._expandedRows.forEach(expandedRow => {
256
+ this.collapseDetailView(expandedRow, true);
257
+ });
258
+ this.dataView.endUpdate();
259
+ }
260
+
261
+ /** Colapse an Item so it is not longer seen */
262
+ collapseDetailView(item: any, isMultipleCollapsing = false) {
263
+ if (!isMultipleCollapsing) {
264
+ this.dataView.beginUpdate();
265
+ }
266
+ // Save the details on the collapse assuming onetime loading
267
+ if (this._addonOptions.loadOnce) {
268
+ this.saveDetailView(item);
269
+ }
270
+
271
+ item[`${this._keyPrefix}collapsed`] = true;
272
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
273
+ this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
274
+ }
275
+ item[`${this._keyPrefix}sizePadding`] = 0;
276
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
277
+
278
+ // Remove the item from the expandedRows
279
+ this._expandedRows = this._expandedRows.filter((expRow) => {
280
+ return expRow[this._dataViewIdProperty] !== item[this._dataViewIdProperty];
281
+ });
282
+
283
+ if (!isMultipleCollapsing) {
284
+ this.dataView.endUpdate();
285
+ }
286
+ }
287
+
288
+ /** Expand a row given the dataview item that is to be expanded */
289
+ expandDetailView(item: any) {
290
+ if (this._addonOptions?.singleRowExpand) {
291
+ this.collapseAll();
292
+ }
293
+
294
+ item[`${this._keyPrefix}collapsed`] = false;
295
+ this._expandedRows.push(item);
296
+
297
+ // In the case something went wrong loading it the first time such a scroll of screen before loaded
298
+ if (!item[`${this._keyPrefix}detailContent`]) {
299
+ item[`${this._keyPrefix}detailViewLoaded`] = false;
300
+ }
301
+
302
+ // display pre-loading template
303
+ if (!item[`${this._keyPrefix}detailViewLoaded`] || this._addonOptions.loadOnce !== true) {
304
+ item[`${this._keyPrefix}detailContent`] = this._addonOptions?.preTemplate?.(item);
305
+ } else {
306
+ this.onAsyncResponse.notify({
307
+ item,
308
+ itemDetail: item,
309
+ detailView: item[`${this._keyPrefix}detailContent`],
310
+ grid: this._grid
311
+ });
312
+ this.applyTemplateNewLineHeight(item);
313
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
314
+ return;
315
+ }
316
+
317
+ this.applyTemplateNewLineHeight(item);
318
+ this.dataView.updateItem(item[this._dataViewIdProperty], item);
319
+
320
+ // async server call
321
+ this._addonOptions.process(item);
322
+ }
323
+
324
+ /** Saves the current state of the detail view */
325
+ saveDetailView(item: any) {
326
+ const view = document.querySelector(`.${this.gridUid} .innerDetailView_${item[this._dataViewIdProperty]}`);
327
+ if (view) {
328
+ const html = view.innerHTML;
329
+ if (html !== undefined) {
330
+ item[`${this._keyPrefix}detailContent`] = html;
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished
337
+ * the response has to be as "args.item" (or "args.itemDetail") with it's data back
338
+ */
339
+ handleOnAsyncResponse(e: SlickEventData, args: { item: any; itemDetail: any; detailView?: any; }) {
340
+ if (!args || (!args.item && !args.itemDetail)) {
341
+ console.error('SlickRowDetailView plugin requires the onAsyncResponse() to supply "args.item" property.');
342
+ return;
343
+ }
344
+
345
+ // we accept item/itemDetail, just get the one which has data
346
+ const itemDetail = args.item || args.itemDetail;
347
+
348
+ // If we just want to load in a view directly we can use detailView property to do so
349
+ itemDetail[`${this._keyPrefix}detailContent`] = args.detailView ?? this._addonOptions?.postTemplate?.(itemDetail);
350
+ itemDetail[`${this._keyPrefix}detailViewLoaded`] = true;
351
+ this.dataView.updateItem(itemDetail[this._dataViewIdProperty], itemDetail);
352
+
353
+ // trigger an event once the post template is finished loading
354
+ this.onAsyncEndUpdate.notify({
355
+ grid: this._grid,
356
+ item: itemDetail,
357
+ itemDetail,
358
+ }, e, this);
359
+ }
360
+
361
+ /**
362
+ * TODO interface only has a GETTER not a SETTER..why?
363
+ * Override the logic for showing (or not) the expand icon (use case example: only every 2nd row is expandable)
364
+ * Method that user can pass to override the default behavior or making every row an expandable row.
365
+ * In order word, user can choose which rows to be an available row detail (or not) by providing his own logic.
366
+ * @param overrideFn: override function callback
367
+ */
368
+ expandableOverride(overrideFn: UsabilityOverrideFn) {
369
+ this._expandableOverride = overrideFn;
370
+ }
371
+
372
+ getExpandableOverride(): UsabilityOverrideFn | null {
373
+ return this._expandableOverride;
374
+ }
375
+
376
+ /** Get the Column Definition of the first column dedicated to toggling the Row Detail View */
377
+ getColumnDefinition(): Column {
378
+ const columnId = String(this._addonOptions?.columnId ?? this._defaults.columnId);
379
+
380
+ return {
381
+ id: columnId,
382
+ field: columnId,
383
+ name: '',
384
+ alwaysRenderColumn: this._addonOptions?.alwaysRenderColumn,
385
+ cssClass: this._addonOptions.cssClass || '',
386
+ excludeFromExport: true,
387
+ excludeFromColumnPicker: true,
388
+ excludeFromGridMenu: true,
389
+ excludeFromQuery: true,
390
+ excludeFromHeaderMenu: true,
391
+ formatter: this.detailSelectionFormatter.bind(this),
392
+ reorderable: this._addonOptions.reorderable,
393
+ resizable: false,
394
+ sortable: false,
395
+ toolTip: this._addonOptions.toolTip,
396
+ width: this._addonOptions.width,
397
+ };
398
+ }
399
+
400
+ /** return the currently expanded rows */
401
+ getExpandedRows(): Array<number | string> {
402
+ return this._expandedRows;
403
+ }
404
+
405
+ /** return the rows that are out of the viewport */
406
+ getOutOfViewportRows(): Array<number | string> {
407
+ return this._rowIdsOutOfViewport;
408
+ }
409
+
410
+ /** Takes in the item we are filtering and if it is an expanded row returns it's parents row to filter on */
411
+ getFilterItem(item: any) {
412
+ if (item[`${this._keyPrefix}isPadding`] && item[`${this._keyPrefix}parent`]) {
413
+ item = item[`${this._keyPrefix}parent`];
414
+ }
415
+ return item;
416
+ }
417
+
418
+ /** Resize the Row Detail View */
419
+ resizeDetailView(item: any) {
420
+ if (!item) {
421
+ return;
422
+ }
423
+
424
+ // Grad each of the DOM elements
425
+ const mainContainer = document.querySelector<HTMLDivElement>(`.${this.gridUid} .detailViewContainer_${item[this._dataViewIdProperty]}`);
426
+ const cellItem = document.querySelector<HTMLDivElement>(`.${this.gridUid} .cellDetailView_${item[this._dataViewIdProperty]}`);
427
+ const inner = document.querySelector<HTMLDivElement>(`.${this.gridUid} .innerDetailView_${item[this._dataViewIdProperty]}`);
428
+
429
+ if (!mainContainer || !cellItem || !inner) {
430
+ return;
431
+ }
432
+
433
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
434
+ this.dataView.deleteItem(`${item[this._dataViewIdProperty]}.${idx}`);
435
+ }
436
+
437
+ const rowHeight = this.gridOptions.rowHeight as number; // height of a row
438
+ const lineHeight = 13; // we know cuz we wrote the custom css init ;)
439
+
440
+ // remove the height so we can calculate the height
441
+ mainContainer.style.minHeight = '';
442
+
443
+ // Get the scroll height for the main container so we know the actual size of the view
444
+ const itemHeight = mainContainer.scrollHeight;
445
+
446
+ // Now work out how many rows
447
+ const rowCount = Math.ceil(itemHeight / rowHeight);
448
+
449
+ item[`${this._keyPrefix}sizePadding`] = Math.ceil(((rowCount * 2) * lineHeight) / rowHeight);
450
+ item[`${this._keyPrefix}height`] = itemHeight;
451
+
452
+ let outterHeight = (item[`${this._keyPrefix}sizePadding`] * rowHeight);
453
+ if (this._addonOptions.maxRows !== undefined && item[`${this._keyPrefix}sizePadding`] > this._addonOptions.maxRows) {
454
+ outterHeight = this._addonOptions.maxRows! * rowHeight;
455
+ item[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows;
456
+ }
457
+
458
+ // If the padding is now more than the original minRowBuff we need to increase it
459
+ if (this.gridOptions.minRowBuffer! < item[`${this._keyPrefix}sizePadding`]) {
460
+ // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3
461
+ this.gridOptions.minRowBuffer = item[`${this._keyPrefix}sizePadding`] + 3;
462
+ }
463
+
464
+ mainContainer.setAttribute('style', `min-height: ${item[this._keyPrefix + 'height']}px`);
465
+ if (cellItem) {
466
+ cellItem.setAttribute('style', `height: ${outterHeight}px; top: ${rowHeight}px`);
467
+ }
468
+
469
+ const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]) as number;
470
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
471
+ this.dataView.insertItem(idxParent + idx, this.getPaddingItem(item, idx));
472
+ }
473
+
474
+ // Lastly save the updated state
475
+ this.saveDetailView(item);
476
+ }
477
+
478
+ // --
479
+ // protected functions
480
+ // ------------------
481
+
482
+ /**
483
+ * create the detail ctr node. this belongs to the dev & can be custom-styled as per
484
+ */
485
+ protected applyTemplateNewLineHeight(item: any) {
486
+ // the height is calculated by the template row count (how many line of items does the template view have)
487
+ const rowCount = this._addonOptions.panelRows;
488
+
489
+ // calculate padding requirements based on detail-content..
490
+ // ie. worst-case: create an invisible dom node now & find it's height.
491
+ const lineHeight = 13; // we know cuz we wrote the custom css init ;)
492
+ item[`${this._keyPrefix}sizePadding`] = Math.ceil(((rowCount * 2) * lineHeight) / this.gridOptions.rowHeight!);
493
+ item[`${this._keyPrefix}height`] = (item[`${this._keyPrefix}sizePadding`] * this.gridOptions.rowHeight!);
494
+ const idxParent = this.dataView.getIdxById(item[this._dataViewIdProperty]);
495
+ for (let idx = 1; idx <= item[`${this._keyPrefix}sizePadding`]; idx++) {
496
+ this.dataView.insertItem((idxParent || 0) + idx, this.getPaddingItem(item, idx));
497
+ }
498
+ }
499
+
500
+ protected calculateOutOfRangeViews() {
501
+ if (this._grid) {
502
+ let scrollDir: 'UP' | 'DOWN';
503
+ const renderedRange = this._grid.getRenderedRange();
504
+ // Only check if we have expanded rows
505
+ if (this._expandedRows.length > 0) {
506
+ // Assume scroll direction is down by default.
507
+ scrollDir = 'DOWN';
508
+ if (this._lastRange) {
509
+ // Some scrolling isn't anything as the range is the same
510
+ if (this._lastRange.top === renderedRange.top && this._lastRange.bottom === renderedRange.bottom) {
511
+ return;
512
+ }
513
+
514
+ // If our new top is smaller we are scrolling up
515
+ if (this._lastRange.top > renderedRange.top ||
516
+ // Or we are at very top but our bottom is increasing
517
+ (this._lastRange.top === 0 && renderedRange.top === 0 && (this._lastRange.bottom > renderedRange.bottom))) {
518
+ scrollDir = 'UP';
519
+ }
520
+ }
521
+ }
522
+
523
+ this._expandedRows.forEach((row) => {
524
+ const rowIndex = this.dataView.getRowById(row[this._dataViewIdProperty]) as number;
525
+ const rowPadding = row[`${this._keyPrefix}sizePadding`];
526
+ const isRowOutOfRange = this._rowIdsOutOfViewport.some(rowId => rowId === row[this._dataViewIdProperty]);
527
+
528
+ if (scrollDir === 'UP') {
529
+ // save the view when asked
530
+ if (this._addonOptions.saveDetailViewOnScroll) {
531
+ // If the bottom item within buffer range is an expanded row save it.
532
+ if (rowIndex >= renderedRange.bottom - this._gridRowBuffer) {
533
+ this.saveDetailView(row);
534
+ }
535
+ }
536
+
537
+ // If the row expanded area is within the buffer notify that it is back in range
538
+ if (isRowOutOfRange && ((rowIndex - this._outsideRange) < renderedRange.top) && (rowIndex >= renderedRange.top)) {
539
+ this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
540
+ } else if (!isRowOutOfRange && ((rowIndex + rowPadding) > renderedRange.bottom)) {
541
+ // if our first expanded row is about to go off the bottom
542
+ this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
543
+ }
544
+ } else if (scrollDir === 'DOWN') {
545
+ // save the view when asked
546
+ if (this._addonOptions.saveDetailViewOnScroll) {
547
+ // If the top item within buffer range is an expanded row save it.
548
+ if (rowIndex <= renderedRange.top + this._gridRowBuffer) {
549
+ this.saveDetailView(row);
550
+ }
551
+ }
552
+
553
+ // If row index is i higher than bottom with some added value (To ignore top rows off view) and is with view and was our of range
554
+ if (isRowOutOfRange && ((rowIndex + rowPadding + this._outsideRange) > renderedRange.bottom) && (rowIndex < (rowIndex + rowPadding))) {
555
+ this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
556
+ } else if (!isRowOutOfRange && (rowIndex < renderedRange.top)) {
557
+ // if our row is outside top of and the buffering zone but not in the array of outOfVisable range notify it
558
+ this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
559
+ }
560
+ }
561
+ });
562
+ this._lastRange = renderedRange;
563
+ }
564
+ }
565
+
566
+ protected calculateOutOfRangeViewsSimplerVersion() {
567
+ if (this._grid) {
568
+ const renderedRange = this._grid.getRenderedRange();
569
+
570
+ this._expandedRows.forEach((row) => {
571
+ const rowIndex = this.dataView.getRowById(row[this._dataViewIdProperty]) as number;
572
+ const isOutOfVisibility = this.checkIsRowOutOfViewportRange(rowIndex, renderedRange);
573
+ if (!isOutOfVisibility && this._rowIdsOutOfViewport.some(rowId => rowId === row[this._dataViewIdProperty])) {
574
+ this.notifyBackToViewportWhenDomExist(row, row[this._dataViewIdProperty]);
575
+ } else if (isOutOfVisibility) {
576
+ this.notifyOutOfViewport(row, row[this._dataViewIdProperty]);
577
+ }
578
+ });
579
+ }
580
+ }
581
+
582
+ protected checkExpandableOverride(row: number, dataContext: any, grid: SlickGrid) {
583
+ if (typeof this._expandableOverride === 'function') {
584
+ return this._expandableOverride(row, dataContext, grid);
585
+ }
586
+ return true;
587
+ }
588
+
589
+ protected checkIsRowOutOfViewportRange(rowIndex: number, renderedRange: any) {
590
+ return (Math.abs(renderedRange.bottom - this._gridRowBuffer - rowIndex) > this._visibleRenderedCellCount * 2);
591
+ }
592
+
593
+ /** Get the Row Detail padding (which are the rows dedicated to the detail panel) */
594
+ protected getPaddingItem(parent: any, offset: any) {
595
+ const item: any = {};
596
+
597
+ Object.keys(this.dataView).forEach(prop => {
598
+ if (prop) {
599
+ item[prop] = null;
600
+ }
601
+ });
602
+ item[this._dataViewIdProperty] = `${parent[this._dataViewIdProperty]}.${offset}`;
603
+
604
+ // additional hidden padding metadata fields
605
+ item[`${this._keyPrefix}collapsed`] = true;
606
+ item[`${this._keyPrefix}isPadding`] = true;
607
+ item[`${this._keyPrefix}parent`] = parent;
608
+ item[`${this._keyPrefix}offset`] = offset;
609
+
610
+ return item;
611
+ }
612
+
613
+ /** The Formatter of the toggling icon of the Row Detail */
614
+ protected detailSelectionFormatter(row: number, _cell: number, _val: any, _colDef: Column, dataContext: any, grid: SlickGrid): FormatterResultWithHtml | HTMLElement | '' {
615
+ if (!this.checkExpandableOverride(row, dataContext, grid)) {
616
+ return '';
617
+ } else {
618
+ if (dataContext[`${this._keyPrefix}collapsed`] === undefined) {
619
+ dataContext[`${this._keyPrefix}collapsed`] = true;
620
+ dataContext[`${this._keyPrefix}sizePadding`] = 0; // the required number of pading rows
621
+ dataContext[`${this._keyPrefix}height`] = 0; // the actual height in pixels of the detail field
622
+ dataContext[`${this._keyPrefix}isPadding`] = false;
623
+ dataContext[`${this._keyPrefix}parent`] = undefined;
624
+ dataContext[`${this._keyPrefix}offset`] = 0;
625
+ }
626
+
627
+ if (dataContext[`${this._keyPrefix}isPadding`]) {
628
+ // render nothing
629
+ } else if (dataContext[`${this._keyPrefix}collapsed`]) {
630
+ let collapsedClasses = `${this._addonOptions.cssClass || ''} expand `;
631
+ if (this._addonOptions.collapsedClass) {
632
+ collapsedClasses += this._addonOptions.collapsedClass;
633
+ }
634
+ return createDomElement('div', { className: classNameToList(collapsedClasses).join(' ') });
635
+ } else {
636
+ const rowHeight = this.gridOptions.rowHeight || 0;
637
+ let outterHeight = (dataContext[`${this._keyPrefix}sizePadding`] || 0) * this.gridOptions.rowHeight!;
638
+
639
+ if (this._addonOptions.maxRows !== null && ((dataContext[`${this._keyPrefix}sizePadding`] || 0) > this._addonOptions.maxRows!)) {
640
+ outterHeight = this._addonOptions.maxRows! * rowHeight!;
641
+ dataContext[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows;
642
+ }
643
+
644
+ // sneaky extra </div> inserted here-----------------v
645
+ let expandedClasses = `${this._addonOptions.cssClass || ''} collapse `;
646
+ if (this._addonOptions.expandedClass) {
647
+ expandedClasses += this._addonOptions.expandedClass;
648
+ }
649
+
650
+ // create the Row Detail div container that will be inserted AFTER the `.slick-cell`
651
+ const cellDetailContainerElm = createDomElement('div', {
652
+ className: `dynamic-cell-detail cellDetailView_${dataContext[this._dataViewIdProperty]}`,
653
+ style: { height: `${outterHeight}px`, top: `${rowHeight}px` }
654
+ });
655
+ const innerContainerElm = createDomElement('div', { className: `detail-container detailViewContainer_${dataContext[this._dataViewIdProperty]}` });
656
+ const innerDetailViewElm = createDomElement('div', { className: `innerDetailView_${dataContext[this._dataViewIdProperty]}` });
657
+ if (dataContext[`${this._keyPrefix}detailContent`] instanceof HTMLElement) {
658
+ innerDetailViewElm.appendChild(dataContext[`${this._keyPrefix}detailContent`]);
659
+ } else {
660
+ innerDetailViewElm.innerHTML = this._grid.sanitizeHtmlString(dataContext[`${this._keyPrefix}detailContent`]);
661
+ }
662
+
663
+ innerContainerElm.appendChild(innerDetailViewElm);
664
+ cellDetailContainerElm.appendChild(innerContainerElm);
665
+
666
+ const result: FormatterResultWithHtml = {
667
+ html: createDomElement('div', { className: classNameToList(expandedClasses).join(' ') }),
668
+ insertElementAfterTarget: cellDetailContainerElm,
669
+ };
670
+
671
+ return result;
672
+ }
673
+ }
674
+ return '';
675
+ }
676
+
677
+ /** When row is getting toggled, we will handle the action of collapsing/expanding */
678
+ protected handleAccordionShowHide(item: any) {
679
+ if (item) {
680
+ if (!item[`${this._keyPrefix}collapsed`]) {
681
+ this.collapseDetailView(item);
682
+ } else {
683
+ this.expandDetailView(item);
684
+ }
685
+ }
686
+ }
687
+
688
+ /** Handle mouse click event */
689
+ protected handleClick(e: SlickEventData, args: { row: number; cell: number; }) {
690
+ const dataContext = this._grid.getDataItem(args.row);
691
+
692
+ if (this.checkExpandableOverride(args.row, dataContext, this._grid)) {
693
+ // clicking on a row select checkbox
694
+ const columnDef = this._grid.getColumns()[args.cell];
695
+ if (this._addonOptions.useRowClick || (columnDef.id === this._addonOptions.columnId && e.target!.classList.contains(this._addonOptions.cssClass || ''))) {
696
+ // if editing, try to commit
697
+ if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) {
698
+ e.preventDefault();
699
+ e.stopImmediatePropagation();
700
+ return;
701
+ }
702
+
703
+ // trigger an event before toggling
704
+ // user could cancel the Row Detail opening when event is returning false
705
+ const ignorePrevEventDataValue = true; // click event might return false from Row Selection canCellBeActive() validation, we need to ignore that
706
+ if (this.onBeforeRowDetailToggle.notify({ grid: this._grid, item: dataContext }, e, this, ignorePrevEventDataValue).getReturnValue() === false) {
707
+ return;
708
+ }
709
+
710
+ this.toggleRowSelection(args.row, dataContext);
711
+
712
+ // trigger an event after toggling
713
+ this.onAfterRowDetailToggle.notify({
714
+ grid: this._grid,
715
+ item: dataContext,
716
+ expandedRows: this._expandedRows,
717
+ }, e, this);
718
+
719
+ e.stopPropagation();
720
+ e.stopImmediatePropagation();
721
+ }
722
+ }
723
+ }
724
+
725
+ protected handleScroll() {
726
+ if (this._addonOptions.useSimpleViewportCalc) {
727
+ this.calculateOutOfRangeViewsSimplerVersion();
728
+ } else {
729
+ this.calculateOutOfRangeViews();
730
+ }
731
+ }
732
+
733
+ protected notifyOutOfViewport(item: any, rowId: number | string) {
734
+ const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
735
+
736
+ this.onRowOutOfViewportRange.notify({
737
+ grid: this._grid,
738
+ item,
739
+ rowId,
740
+ rowIndex,
741
+ expandedRows: this._expandedRows,
742
+ rowIdsOutOfViewport: this.syncOutOfViewportArray(rowId, true)
743
+ }, null, this);
744
+ }
745
+
746
+ protected notifyBackToViewportWhenDomExist(item: any, rowId: number | string) {
747
+ const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]);
748
+
749
+ setTimeout(() => {
750
+ // make sure View Row DOM Element really exist before notifying that it's a row that is visible again
751
+ if (document.querySelector(`.${this.gridUid} .cellDetailView_${item[this._dataViewIdProperty]}`)) {
752
+ this.onRowBackToViewportRange.notify({
753
+ grid: this._grid,
754
+ item,
755
+ rowId,
756
+ rowIndex,
757
+ expandedRows: this._expandedRows,
758
+ rowIdsOutOfViewport: this.syncOutOfViewportArray(rowId, false)
759
+ }, null, this);
760
+ }
761
+ }, 100);
762
+ }
763
+
764
+ protected syncOutOfViewportArray(rowId: number | string, isAdding: boolean) {
765
+ const arrayRowIndex = this._rowIdsOutOfViewport.findIndex(outOfViewportRowId => outOfViewportRowId === rowId);
766
+
767
+ if (isAdding && arrayRowIndex < 0) {
768
+ this._rowIdsOutOfViewport.push(rowId);
769
+ } else if (!isAdding && arrayRowIndex >= 0) {
770
+ this._rowIdsOutOfViewport.splice(arrayRowIndex, 1);
771
+ }
772
+ return this._rowIdsOutOfViewport;
773
+ }
774
+
775
+ protected toggleRowSelection(rowNumber: number, dataContext: any) {
776
+ if (this.checkExpandableOverride(rowNumber, dataContext, this._grid)) {
777
+ this.dataView.beginUpdate();
778
+ this.handleAccordionShowHide(dataContext);
779
+ this.dataView.endUpdate();
780
+ }
781
+ }
782
+ }