@open-dcp/grid 0.0.1 → 0.2.17

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.
Files changed (46) hide show
  1. package/.github/workflows/main.yml +42 -0
  2. package/CLA.md +22 -0
  3. package/LICENSE +190 -0
  4. package/NOTICE.md +31 -0
  5. package/README.md +39 -39
  6. package/SECURITY.md +27 -0
  7. package/dist/GridService.d.ts +83 -0
  8. package/dist/GridService.js +410 -0
  9. package/dist/__tests__/GridServices.test.d.ts +1 -0
  10. package/dist/__tests__/GridServices.test.js +288 -0
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +1 -0
  13. package/dist/services/FilteringService.d.ts +34 -0
  14. package/dist/services/FilteringService.js +278 -0
  15. package/dist/services/FormattingService.d.ts +17 -0
  16. package/dist/services/FormattingService.js +179 -0
  17. package/dist/services/RowHeightService.d.ts +15 -0
  18. package/dist/services/RowHeightService.js +63 -0
  19. package/dist/services/SelectionService.d.ts +14 -0
  20. package/dist/services/SelectionService.js +55 -0
  21. package/dist/services/SortingService.d.ts +15 -0
  22. package/dist/services/SortingService.js +82 -0
  23. package/dist/services/__tests__/FilteringService.test.d.ts +1 -0
  24. package/dist/services/__tests__/FilteringService.test.js +140 -0
  25. package/dist/services/__tests__/FormattingService.test.d.ts +1 -0
  26. package/dist/services/__tests__/FormattingService.test.js +122 -0
  27. package/dist/types/grid.d.ts +71 -0
  28. package/dist/types/grid.js +1 -0
  29. package/dist/types/sorting.d.ts +8 -0
  30. package/dist/types/sorting.js +1 -0
  31. package/jest.config.js +16 -0
  32. package/package.json +40 -9
  33. package/reports/junit.xml +3 -0
  34. package/src/GridService.ts +561 -0
  35. package/src/__tests__/GridServices.test.ts +348 -0
  36. package/src/index.ts +17 -0
  37. package/src/services/FilteringService.ts +347 -0
  38. package/src/services/FormattingService.ts +205 -0
  39. package/src/services/RowHeightService.ts +81 -0
  40. package/src/services/SelectionService.ts +71 -0
  41. package/src/services/SortingService.ts +105 -0
  42. package/src/services/__tests__/FilteringService.test.ts +173 -0
  43. package/src/services/__tests__/FormattingService.test.ts +157 -0
  44. package/src/types/grid.ts +84 -0
  45. package/src/types/sorting.ts +9 -0
  46. package/tsconfig.json +19 -0
@@ -0,0 +1,561 @@
1
+ import { BehaviorSubject, Observable, skip, combineLatest, map } from 'rxjs';
2
+ import { isEqual } from 'lodash-es';
3
+ import { distinctUntilChanged } from 'rxjs/operators';
4
+ import {
5
+ GridState,
6
+ Column,
7
+ Row,
8
+ RenderRow,
9
+ DisplayFormat,
10
+ DisplayValue,
11
+ GridOptions,
12
+ FilterSortUpdatePayload,
13
+ GlobalSearchOptions,
14
+ GlobalSearchState,
15
+ } from './types/grid';
16
+ import { FilterNode } from '@open-dcp/filter';
17
+ import { logger } from '@open-dcp/utilities';
18
+ import { SortingCondition, ApiModeConfig } from './types/sorting';
19
+
20
+ import { RowHeightService } from './services/RowHeightService';
21
+ import { SortingService } from './services/SortingService';
22
+ import { FormattingService } from './services/FormattingService';
23
+ import { FilteringService } from './services/FilteringService';
24
+ import { SelectionService } from './services/SelectionService';
25
+
26
+ const DEFAULT_DISPLAY_FORMAT: Required<DisplayFormat> = {
27
+ timeZone: "UTC",
28
+ dateFormat: "yyyy-MM-dd",
29
+ timeFormat: "HH:mm:ss",
30
+ };
31
+
32
+ export class GridService {
33
+ public readonly elementHeight: number;
34
+ public readonly gridOptions: Partial<GridOptions>;
35
+
36
+ private readonly rowsSubject: BehaviorSubject<Row[]>;
37
+ private readonly columnsSubject: BehaviorSubject<Column[]>;
38
+ private readonly stateSubject: BehaviorSubject<GridState>;
39
+
40
+ public displayFormatSubject: BehaviorSubject<DisplayFormat>;
41
+ public displayFormat$: Observable<DisplayFormat>;
42
+ public rows$: Observable<Row[]>;
43
+ public rowCount$: Observable<number>;
44
+ public columns$: Observable<Column[]>;
45
+ private container: HTMLElement | null = null; // Reference to the scrolling container
46
+ public startIndex: number = 0;
47
+ private firstRowHeight: number = 0;
48
+ private lastRowHeight: number = 0;
49
+
50
+ private apiMode: boolean = false;
51
+ private clientSideSort: boolean = false;
52
+ private clientSideFilter: boolean = false;
53
+
54
+ private readonly rowHeightService: RowHeightService;
55
+ private readonly sortingService: SortingService;
56
+ private readonly formattingService: FormattingService;
57
+ private readonly filteringService: FilteringService;
58
+ private readonly selectionService: SelectionService;
59
+
60
+ private filterSortSubject: BehaviorSubject<FilterSortUpdatePayload>;
61
+ public filterSort$: Observable<FilterSortUpdatePayload>;
62
+
63
+ private indexMapping: Map<number, number> = new Map();
64
+
65
+ constructor(rows: Row[], columns: Column[], gridOptions: Partial<GridOptions> = {}, sorts: SortingCondition[] = [], filterTree: FilterNode | null = null, selections: Set<number> = new Set()) {
66
+
67
+ this.gridOptions = gridOptions;
68
+
69
+ this.filterSortSubject = new BehaviorSubject<FilterSortUpdatePayload>({ filterTree, sorts, globalSearch: null });
70
+ this.filterSort$ = this.filterSortSubject.asObservable();
71
+
72
+ this.displayFormatSubject = new BehaviorSubject<DisplayFormat>({
73
+ ...DEFAULT_DISPLAY_FORMAT,
74
+ ...gridOptions.displayFormat
75
+ });
76
+ this.displayFormat$ = this.displayFormatSubject.asObservable();
77
+
78
+ // Data independend services
79
+ this.formattingService = new FormattingService(this);
80
+ this.rowHeightService = new RowHeightService(this);
81
+
82
+ // Set Column
83
+ this.columnsSubject = new BehaviorSubject(columns);
84
+ this.columns$ = this.columnsSubject.asObservable();
85
+
86
+ // Format Rows
87
+ let initalRows = rows.map(row => this.formattingService.applyFormattingToRow(row));
88
+ this.rowsSubject = new BehaviorSubject(initalRows);
89
+ this.rows$ = this.rowsSubject.asObservable();
90
+ this.indexMapping = new Map([...Array(initalRows.length).keys()].map(i => [i, i]))
91
+
92
+ // Row Count Observable
93
+ this.rowCount$ = combineLatest([
94
+ this.rows$,
95
+ this.filterSort$,
96
+ this.columns$
97
+ ]).pipe(
98
+ map(() => this.rowCount()),
99
+ distinctUntilChanged()
100
+ );
101
+
102
+ // Init Data Services
103
+ this.filteringService = new FilteringService(this, filterTree);
104
+ this.sortingService = new SortingService(this, sorts);
105
+ this.selectionService = new SelectionService(selections, gridOptions.multiSelect ?? true);
106
+
107
+ this.elementHeight = gridOptions.elementHeight ?? 42;
108
+
109
+ this.stateSubject = new BehaviorSubject<GridState>({
110
+ startIndex: 0,
111
+ step: 1,
112
+ firstRowHeight: 0,
113
+ lastRowHeight: 0,
114
+ renderRows: []
115
+ });
116
+
117
+ this.rows$.pipe(
118
+ skip(1),
119
+ distinctUntilChanged((prev, curr) => isEqual(curr, prev))
120
+ ).subscribe(() => {
121
+ if (this.apiMode) {
122
+ return;
123
+ }
124
+
125
+ this.updateRenderedRows()
126
+ this.rowHeightService.initRowHeights(this.rowCount())
127
+ });
128
+
129
+ this.columns$.pipe(
130
+ distinctUntilChanged((prev, curr) => isEqual(curr, prev))
131
+ ).subscribe(() => {
132
+ if (this.getRows().length > 0) {
133
+ this.setRows(this.rowsSubject.getValue());
134
+ }
135
+ });
136
+
137
+ this.displayFormat$.subscribe(() => {
138
+ this.updateRenderedRows();
139
+ });
140
+ }
141
+
142
+ // API Support
143
+ private notifyFilterSortUpdate() {
144
+ const filterTree = this.filteringService.getFilterTree();
145
+ const sorts = this.sortingService.getSorts();
146
+ const globalSearch = this.filteringService.getGlobalSearch();
147
+
148
+ // Always emit to observable
149
+ this.filterSortSubject.next({
150
+ filterTree,
151
+ sorts,
152
+ globalSearch,
153
+ });
154
+
155
+ if (this.apiMode && !this.clientSideSort && !this.clientSideFilter) {
156
+ // In API mode or with client-side operations, do not update locally
157
+ return;
158
+ }
159
+
160
+ this.updateRenderedRows();
161
+ }
162
+
163
+ public enableApiMode(config: ApiModeConfig = {}): void {
164
+ logger.log('DcpGrid: api mode active');
165
+ this.apiMode = true;
166
+ this.clientSideSort = config.clientSideSort ?? false;
167
+ this.clientSideFilter = config.clientSideFilter ?? false;
168
+ }
169
+
170
+ public disableApiMode(): void {
171
+ this.apiMode = false;
172
+ this.clientSideSort = false;
173
+ this.clientSideFilter = false;
174
+ }
175
+
176
+ public shouldApplyClientSideFiltering(): boolean {
177
+ return !this.apiMode || this.clientSideFilter;
178
+ }
179
+
180
+ public setRowsFromApi(rows: Row[]) {
181
+ if (!this.apiMode) {
182
+ logger.warn('DcpGrid: setRowsFromApi should only be called in API mode');
183
+ return;
184
+ }
185
+
186
+ // Format the rows first!
187
+ const formattedRows = rows.map(row => this.formattingService.applyFormattingToRow(row));
188
+
189
+ // Set the formatted rows
190
+ this.rowsSubject.next(formattedRows);
191
+
192
+ // Reset index mapping - it will be recalculated based on client-side operations
193
+ this.indexMapping = new Map([...Array(formattedRows.length).keys()].map(i => [i, i]));
194
+
195
+ this.rowHeightService.initRowHeights(formattedRows.length);
196
+ this.updateRenderedRows();
197
+ }
198
+
199
+ // Index Mapping
200
+ clearIndexMap(): void {
201
+ this.indexMapping.clear();
202
+ }
203
+
204
+ setIndexMapping(newMap: Map<number, number>): void {
205
+ this.indexMapping = newMap
206
+ }
207
+
208
+ setIndexMappingValue(newIndex: number, originalIndex: number): void {
209
+ this.indexMapping.set(newIndex, originalIndex)
210
+ }
211
+
212
+ getOriginalIndex(index: number): number {
213
+ const originalIndex = this.indexMapping.get(index);
214
+
215
+ if (originalIndex === undefined) {
216
+ throw new Error(`Original index in rows not found for: ${index}`);
217
+ }
218
+
219
+ return originalIndex;
220
+ }
221
+
222
+ getIndexMapping(): Map<number, number> {
223
+ return this.indexMapping;
224
+ }
225
+
226
+ // Others
227
+ updateDisplayFormat(newFormat: Partial<DisplayFormat>) {
228
+ this.displayFormatSubject.next({
229
+ ...this.displayFormatSubject.getValue(),
230
+ ...newFormat
231
+ });
232
+
233
+ const globalSearch = this.filteringService.getGlobalSearch();
234
+ if (globalSearch?.options?.searchMode === 'display') {
235
+ this.filteringService.updateFilterIndexesForUpdatedData();
236
+ }
237
+ }
238
+
239
+ attachContainer(container: HTMLElement) {
240
+ this.container = container;
241
+ this.rowHeightService.recalculateHeights();
242
+ this.updateRenderedRows();
243
+ }
244
+
245
+ getContainer(): HTMLElement | null {
246
+ return this.container;
247
+ }
248
+
249
+ setStateValue(key: string, newValue: any): void {
250
+ this.stateSubject.next({
251
+ ...this.stateSubject.value,
252
+ [key]: newValue
253
+ })
254
+ }
255
+
256
+ getState(): Observable<GridState> {
257
+ return this.stateSubject.asObservable();
258
+ }
259
+
260
+ getFirstRowHeight(): number {
261
+ return this.firstRowHeight;
262
+ }
263
+
264
+ getLastRowHeight(): number {
265
+ return this.lastRowHeight;
266
+ }
267
+
268
+ getRenderRows() {
269
+ return this.stateSubject.getValue().renderRows;
270
+ }
271
+
272
+ getRows() {
273
+ return this.rowsSubject.getValue();
274
+ }
275
+
276
+ getCellDisplayValue(row: Row, columnKey: string | number): string {
277
+ const column = this.columnsSubject.getValue().find((col) => col.key === columnKey);
278
+
279
+ if (!column) {
280
+ return '';
281
+ }
282
+
283
+ const displayValue = this.formattingService.formatCellDisplay(row, column).display;
284
+ return displayValue === null || displayValue === undefined ? '' : String(displayValue);
285
+ }
286
+
287
+ getFilteredRows() {
288
+ if (this.apiMode && !this.clientSideFilter) {
289
+ // In API mode without client-side filtering, return rows as-is (already filtered by backend)
290
+ return this.getRows();
291
+ }
292
+
293
+ // Apply client-side filtering
294
+ return this.filteringService.applyFilter(this.getRows());
295
+ }
296
+
297
+ getFilteredAndSortRows() {
298
+ const filteredRows = this.getFilteredRows();
299
+
300
+ if (this.apiMode && !this.clientSideSort) {
301
+ // In API mode without client-side sorting, return rows as-is (already sorted by backend)
302
+ return filteredRows;
303
+ }
304
+
305
+ // Apply client-side sorting
306
+ return this.sortingService.applySorting(filteredRows);
307
+ }
308
+
309
+ rowCount(): number {
310
+ let totalRows = this.rowsSubject.getValue().length;
311
+
312
+ if (this.apiMode && !this.clientSideFilter) {
313
+ // In API mode without client-side filtering, use total rows
314
+ return totalRows;
315
+ }
316
+
317
+ // Use filtered count
318
+ let filterIndexes = this.filteringService.getFilterIndexes()
319
+
320
+ if (filterIndexes === null) {
321
+ return totalRows;
322
+ }
323
+
324
+ return filterIndexes.length;
325
+ }
326
+
327
+ setRows(rows: Row[], shouldFormat: boolean = true): void {
328
+ const updatedRows = shouldFormat
329
+ ? rows.map(row => this.formattingService.applyFormattingToRow(row))
330
+ : rows;
331
+
332
+ this.indexMapping = new Map([...Array(rows.length).keys()].map(i => [i, i]))
333
+
334
+ this.rowsSubject.next(updatedRows);
335
+
336
+ this.filteringService.updateFilterIndexesForUpdatedData();
337
+ }
338
+
339
+ getColumns() {
340
+ return this.columnsSubject.getValue();
341
+ }
342
+
343
+ setColumns(columns: Column[]): void {
344
+ this.columnsSubject.next(columns);
345
+ }
346
+
347
+ // SortingService
348
+ getSortCondition(columnKey: string | number): SortingCondition | null {
349
+ let matchingConditions = this.sortingService.getSortCondition(columnKey)
350
+ if (matchingConditions.length == 0) {
351
+ return null;
352
+ }
353
+
354
+ return matchingConditions[0];
355
+ }
356
+
357
+ toggleSort(columnKey: string | number) {
358
+ this.sortingService.toggleSort(columnKey);
359
+ this.notifyFilterSortUpdate();
360
+ }
361
+
362
+ // FilteringService
363
+ addFilter(filterNode: FilterNode): void {
364
+ this.filteringService.addFilter(filterNode);
365
+ this.sortingService.resetSorting();
366
+
367
+ this.notifyFilterSortUpdate();
368
+ }
369
+
370
+ reset(): void {
371
+ this.filteringService.resetFilters();
372
+ this.sortingService.resetSorting();
373
+ this.notifyFilterSortUpdate();
374
+ }
375
+
376
+ resetFilter(columnKey: string | number): void {
377
+ this.filteringService.resetFilter(columnKey);
378
+ this.sortingService.resetSorting();
379
+ this.notifyFilterSortUpdate();
380
+ }
381
+
382
+ setGlobalSearch(query: string, options?: GlobalSearchOptions): void {
383
+ this.filteringService.setGlobalSearch(query, options);
384
+ this.notifyFilterSortUpdate();
385
+ }
386
+
387
+ clearGlobalSearch(): void {
388
+ this.filteringService.clearGlobalSearch();
389
+ this.notifyFilterSortUpdate();
390
+ }
391
+
392
+ getGlobalSearch(): GlobalSearchState | null {
393
+ return this.filteringService.getGlobalSearch();
394
+ }
395
+
396
+ getFilterIndexes(): number[] | null {
397
+ return this.filteringService.getFilterIndexes();
398
+ }
399
+
400
+ hasFilter(columnKey: string | number): boolean {
401
+ return this.filteringService.hasFilter(columnKey);
402
+ }
403
+
404
+ // SelectionService
405
+ getSelected() {
406
+ return this.selectionService.selected$;
407
+ }
408
+
409
+ selectRow(index: number) {
410
+ this.selectionService.selectRow(index);
411
+ }
412
+
413
+ deselectRow(index: number) {
414
+ this.selectionService.deselectRow(index);
415
+ }
416
+
417
+ toggleRowSelection(index: number) {
418
+ this.selectionService.toggleRowSelection(index);
419
+ }
420
+
421
+ selectAll() {
422
+ const rowsOfInterest = this.getRowsOfInterest();
423
+ const indices = rowsOfInterest.map((_, i) => i);
424
+ this.selectionService.selectAll(indices);
425
+ }
426
+
427
+ shiftSelect(startIndex: number, endIndex: number) {
428
+ this.selectionService.shiftSelect(startIndex, endIndex);
429
+ }
430
+
431
+ isSelected(index: number): boolean {
432
+ return this.selectionService.isSelected(index);
433
+ }
434
+
435
+ deselectAll() {
436
+ this.selectionService.deselectAll();
437
+ }
438
+
439
+ clearSelection() {
440
+ this.selectionService.clearSelection();
441
+ }
442
+
443
+ private getDisplayValue(row: Row, column: Column): DisplayValue {
444
+ return this.formattingService.formatCellDisplay(row, column);
445
+ }
446
+
447
+ private getRowsOfInterest(): Row[] {
448
+ if (this.apiMode) {
449
+ // API mode: apply only client-side operations
450
+ if (this.clientSideFilter && this.clientSideSort) {
451
+ return this.getFilteredAndSortRows();
452
+ } else if (this.clientSideFilter) {
453
+ return this.getFilteredRows();
454
+ } else if (this.clientSideSort) {
455
+ return this.sortingService.applySorting(this.getRows());
456
+ } else {
457
+ // Pure API mode - no client-side operations
458
+ return this.getRows();
459
+ }
460
+ } else {
461
+ // Normal mode - always apply both
462
+ return this.getFilteredAndSortRows();
463
+ }
464
+ }
465
+
466
+ private rebuildIndexMapping(processedRows: Row[]): void {
467
+ const originalRows = this.getRows();
468
+ const newMapping = new Map<number, number>();
469
+
470
+ processedRows.forEach((processedRow, newIdx) => {
471
+ const originalIdx = originalRows.indexOf(processedRow);
472
+ if (originalIdx !== -1) {
473
+ newMapping.set(newIdx, originalIdx);
474
+ }
475
+ });
476
+
477
+ this.indexMapping = newMapping;
478
+ }
479
+
480
+ updateRenderedRows() {
481
+ if (!this.container) return;
482
+
483
+ const scrollTop = this.getScrollHeight() ?? 0;
484
+
485
+ // Get the appropriate rows based on mode and settings
486
+ const rowsOfInterest = this.getRowsOfInterest();
487
+
488
+ // Rebuild index mapping ONLY if client-side operations were applied in API mode
489
+ if (this.apiMode && (this.clientSideFilter || this.clientSideSort)) {
490
+ this.rebuildIndexMapping(rowsOfInterest);
491
+ } else if (this.apiMode && !this.clientSideFilter && !this.clientSideSort) {
492
+ // Pure API mode - ensure 1:1 mapping
493
+ const rows = this.getRows();
494
+ this.indexMapping = new Map([...Array(rows.length).keys()].map(i => [i, i]));
495
+ }
496
+
497
+ this.rowHeightService.recalculateHeights();
498
+
499
+ const scrollBottom = scrollTop + this.container.clientHeight;
500
+ this.startIndex = this.rowHeightService.findStartIndex(scrollTop);
501
+ const endIndex = this.rowHeightService.findEndIndex(scrollBottom);
502
+
503
+ // Correct firstRowHeight using cumulative heights
504
+ this.firstRowHeight = this.startIndex > 0 && rowsOfInterest.length > 0 ? this.rowHeightService.getHeightCache()[this.startIndex - 1] : 0;
505
+
506
+ // Calculate the actual visible height
507
+ const totalHeight = this.rowHeightService.getHeightCache()[this.rowHeightService.getHeightCache().length - 1] || 0;
508
+ const visibleHeight = this.rowHeightService.getHeightCache()[endIndex] - this.firstRowHeight;
509
+
510
+ // Ensure lastRowHeight is computed dynamically
511
+ this.lastRowHeight = rowsOfInterest.length > 0 ? Math.max(0, totalHeight - (this.firstRowHeight + visibleHeight)) : 0;
512
+
513
+ // Slice visible rows
514
+ const visibleRows = rowsOfInterest.slice(this.startIndex, endIndex + 1);
515
+
516
+ // Apply formatting
517
+ const formattedRenderRows: RenderRow[] = visibleRows.map((row: Row, index: number) => {
518
+ const currentIndex = this.startIndex + index;
519
+
520
+ // In pure API mode (no client-side operations), use currentIndex directly
521
+ const originalIndex = (this.apiMode && !this.clientSideFilter && !this.clientSideSort)
522
+ ? currentIndex
523
+ : this.indexMapping.get(currentIndex) ?? currentIndex;
524
+
525
+ const formattedRow: RenderRow = {
526
+ globaIndex: originalIndex,
527
+ data: row
528
+ };
529
+
530
+ this.columnsSubject.getValue().forEach((column: Column) => {
531
+ const cellValue = formattedRow.data[column.key];
532
+ if (cellValue && typeof cellValue === 'object' && cellValue.raw !== undefined) {
533
+ const displayValue = this.getDisplayValue(row, column);
534
+ formattedRow.data[column.key].display = displayValue.display;
535
+ formattedRow.data[column.key].renderedElement = displayValue.renderedElement;
536
+ }
537
+ });
538
+ return formattedRow;
539
+ });
540
+
541
+ // Update state
542
+ this.stateSubject.next({
543
+ startIndex: this.startIndex,
544
+ step: endIndex - this.startIndex,
545
+ firstRowHeight: this.firstRowHeight,
546
+ lastRowHeight: this.lastRowHeight,
547
+ renderRows: formattedRenderRows
548
+ });
549
+
550
+ requestAnimationFrame(() => this.rowHeightService.updateRowHeights());
551
+ }
552
+
553
+ handleScroll(event: Event) {
554
+ if (!this.container) return;
555
+ this.updateRenderedRows();
556
+ }
557
+
558
+ getScrollHeight(): number | undefined {
559
+ return this.container?.scrollTop;
560
+ }
561
+ }