@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,347 @@
1
+ import { BehaviorSubject } from "rxjs";
2
+ import type { GridService } from "../GridService";
3
+ import type { CellValue, Column, GlobalSearchMode, GlobalSearchOptions, GlobalSearchState, Row } from "../types/grid";
4
+ import { FilterCondition, FilterNode, evaluateFilterTree } from "@open-dcp/filter";
5
+ import { logger } from '@open-dcp/utilities';
6
+
7
+ export class FilteringService {
8
+ private readonly gridService: GridService;
9
+ private readonly filteredIndexes$: BehaviorSubject<number[] | null> = new BehaviorSubject<number[] | null>(null);
10
+ private filterTree: FilterNode | null = null;
11
+ private globalSearch: GlobalSearchState | null = null;
12
+ private searchableTextCache: string[] | null = null;
13
+ private cacheSignature: string | null = null;
14
+
15
+ constructor(gridService: GridService, filterTree: FilterNode | null = null) {
16
+ this.gridService = gridService;
17
+ this.filterTree = filterTree;
18
+ }
19
+
20
+ private getColumnKeys(node: FilterNode): (string | number)[] {
21
+ if (node.type === 'condition') return [node.columnKey];
22
+ if (node.type === 'group') {
23
+ return node.filters.reduce<(string | number)[]>((acc, filter) => {
24
+ return acc.concat(this.getColumnKeys(filter));
25
+ }, []);
26
+ }
27
+ return [];
28
+ }
29
+
30
+ setFilterTree(tree: FilterNode | null) {
31
+ this.filterTree = tree;
32
+ this.updateFilterIndexes();
33
+ }
34
+
35
+ getFilterTree(): FilterNode | null {
36
+ return this.filterTree;
37
+ }
38
+
39
+ setGlobalSearch(query: string, options?: GlobalSearchOptions): void {
40
+ const normalizedQuery = query.trim();
41
+
42
+ if (normalizedQuery.length === 0) {
43
+ this.globalSearch = null;
44
+ this.updateFilterIndexes();
45
+ return;
46
+ }
47
+
48
+ this.globalSearch = {
49
+ query: normalizedQuery,
50
+ options: options ? { ...options } : undefined,
51
+ };
52
+
53
+ this.updateFilterIndexes();
54
+ }
55
+
56
+ clearGlobalSearch(): void {
57
+ this.globalSearch = null;
58
+ this.updateFilterIndexes();
59
+ }
60
+
61
+ getGlobalSearch(): GlobalSearchState | null {
62
+ return this.globalSearch;
63
+ }
64
+
65
+ updateFilterIndexesForUpdatedData() {
66
+ this.invalidateSearchCache();
67
+ this.updateFilterIndexes();
68
+ }
69
+
70
+ private invalidateSearchCache(): void {
71
+ this.searchableTextCache = null;
72
+ this.cacheSignature = null;
73
+ }
74
+
75
+ private getSearchColumns(options?: GlobalSearchOptions): Column[] {
76
+ const allColumns = this.gridService.getColumns();
77
+
78
+ if (options?.columns && options.columns.length > 0) {
79
+ const keySet = new Set(options.columns);
80
+ const filteredColumns = allColumns.filter((column) => keySet.has(column.key));
81
+
82
+ if (filteredColumns.length === 0) {
83
+ logger.warn('DcpGrid: Global search columns do not match any configured columns');
84
+ }
85
+
86
+ return filteredColumns;
87
+ }
88
+
89
+ return allColumns;
90
+ }
91
+
92
+ private toSearchValue(value: unknown): string {
93
+ if (value === null || value === undefined) {
94
+ return '';
95
+ }
96
+
97
+ if (value instanceof Date) {
98
+ return value.toISOString();
99
+ }
100
+
101
+ return String(value);
102
+ }
103
+
104
+ private getCellSearchValue(row: Row, column: Column, searchMode: GlobalSearchMode): string {
105
+ const columnKey = column.key;
106
+ const cell = row[columnKey];
107
+ const mappedCell = (cell && typeof cell === 'object') ? (cell as unknown as Record<string, unknown>) : null;
108
+
109
+ if (typeof column.globalSearchText === 'function') {
110
+ const customValue = column.globalSearchText(mappedCell as CellValue | null, row, searchMode);
111
+ const customText = this.toSearchValue(customValue);
112
+ if (customText.length > 0) {
113
+ return customText;
114
+ }
115
+ }
116
+
117
+ if (mappedCell) {
118
+
119
+ if (searchMode === 'raw' && 'raw' in mappedCell) {
120
+ return this.toSearchValue(mappedCell.raw);
121
+ }
122
+
123
+ if (searchMode === 'display') {
124
+ if ('display' in mappedCell) {
125
+ const displayText = this.toSearchValue(mappedCell.display);
126
+ if (displayText.length > 0) {
127
+ return displayText;
128
+ }
129
+ }
130
+
131
+ const formattedDisplay = this.gridService.getCellDisplayValue(row, columnKey);
132
+ if (formattedDisplay.length > 0) {
133
+ return formattedDisplay;
134
+ }
135
+
136
+ if ('value' in mappedCell) {
137
+ return this.toSearchValue(mappedCell.value);
138
+ }
139
+ if ('raw' in mappedCell) {
140
+ return this.toSearchValue(mappedCell.raw);
141
+ }
142
+ }
143
+
144
+ if ('value' in mappedCell) {
145
+ return this.toSearchValue(mappedCell.value);
146
+ }
147
+ if ('raw' in mappedCell) {
148
+ return this.toSearchValue(mappedCell.raw);
149
+ }
150
+ }
151
+
152
+ return this.toSearchValue(cell);
153
+ }
154
+
155
+ private getCacheSignature(columns: Column[], caseInsensitive: boolean, searchMode: GlobalSearchMode): string {
156
+ const columnKeys = columns.map((column) => String(column.key));
157
+ return `${columnKeys.join('|')}::${caseInsensitive ? 'i' : 's'}::${searchMode}`;
158
+ }
159
+
160
+ private ensureSearchCache(rows: Row[], columns: Column[], caseInsensitive: boolean, searchMode: GlobalSearchMode): void {
161
+ const signature = this.getCacheSignature(columns, caseInsensitive, searchMode);
162
+
163
+ if (
164
+ this.searchableTextCache !== null &&
165
+ this.cacheSignature === signature &&
166
+ this.searchableTextCache.length === rows.length
167
+ ) {
168
+ return;
169
+ }
170
+
171
+ this.searchableTextCache = rows.map(row => {
172
+ const baseText = columns
173
+ .map(column => this.getCellSearchValue(row, column, searchMode))
174
+ .join(' ');
175
+
176
+ return caseInsensitive ? baseText.toLowerCase() : baseText;
177
+ });
178
+ this.cacheSignature = signature;
179
+ }
180
+
181
+ private matchesFilterTree(row: Row): boolean {
182
+ if (!this.filterTree) {
183
+ return true;
184
+ }
185
+
186
+ return evaluateFilterTree(this.filterTree, row, {
187
+ caseInsensitive: this.gridService.gridOptions.caseInsensitiveFiltering ?? false,
188
+ });
189
+ }
190
+
191
+ hasFilter(columnKey: string | number): boolean {
192
+ const checkNode = (node: FilterNode): boolean => {
193
+ if (node.type === 'condition') {
194
+ return node.columnKey === columnKey;
195
+ }
196
+ if (node.type === 'group') {
197
+ return node.filters.some(checkNode);
198
+ }
199
+ return false;
200
+ };
201
+
202
+ if (!this.filterTree) return false;
203
+ return checkNode(this.filterTree);
204
+ }
205
+
206
+ addFilter(filterNode: FilterNode): void {
207
+ const columnKeys = this.getColumnKeys(filterNode);
208
+
209
+ // Start with existing filters (if any)
210
+ let newFilters: FilterNode[] = [];
211
+ if (this.filterTree && this.filterTree.type === 'group') {
212
+ // Remove all filters for the affected column(s)
213
+ newFilters = this.filterTree.filters.filter(f => {
214
+ let keys: (string | number)[];
215
+ if (f.type === 'condition') {
216
+ keys = [f.columnKey];
217
+ } else if (f.type === 'group') {
218
+ keys = f.filters.reduce<(string | number)[]>((acc, ff) => {
219
+ if (ff.type === 'condition') {
220
+ acc.push(ff.columnKey);
221
+ }
222
+ return acc;
223
+ }, []);
224
+ } else {
225
+ keys = [];
226
+ }
227
+ return !keys.some(k => columnKeys.includes(k));
228
+ });
229
+ }
230
+
231
+ // Add the new filter node
232
+ newFilters.push(filterNode);
233
+
234
+ // Set the new filter tree
235
+ this.setFilterTree({
236
+ type: 'group',
237
+ operator: 'and',
238
+ filters: newFilters
239
+ });
240
+ }
241
+
242
+ resetFilters(): void {
243
+ this.setFilterTree(null);
244
+ }
245
+
246
+ resetFilter(columnKey: string | number): void {
247
+ if (!this.filterTree || this.filterTree.type !== 'group') return;
248
+
249
+ // Remove any filter node (condition or group) that applies to this column
250
+ const newFilters = this.filterTree.filters.filter(
251
+ f => !this.getColumnKeys(f).includes(columnKey)
252
+ );
253
+
254
+ // If no filters left, set to null
255
+ if (newFilters.length === 0) {
256
+ this.setFilterTree(null);
257
+ } else {
258
+ this.setFilterTree({
259
+ ...this.filterTree,
260
+ filters: newFilters
261
+ });
262
+ }
263
+ }
264
+
265
+ getFilterIndexes(): number[] | null {
266
+ return this.filteredIndexes$.getValue();
267
+ }
268
+
269
+ getFilters(): FilterCondition[] {
270
+ if (!this.filterTree || this.filterTree.type !== 'group') return [];
271
+ return this.filterTree.filters.filter(
272
+ f => f.type === 'condition'
273
+ ) as FilterCondition[];
274
+ }
275
+
276
+ private updateFilterIndexes(): void {
277
+ const rows = this.gridService.getRows();
278
+
279
+ if (!this.gridService.shouldApplyClientSideFiltering()) {
280
+ this.filteredIndexes$.next(null);
281
+ this.gridService.setIndexMapping(
282
+ new Map([...Array(rows.length).keys()].map(i => [i, i]))
283
+ );
284
+ this.gridService.updateRenderedRows();
285
+ return;
286
+ }
287
+
288
+ if (!this.filterTree && !this.globalSearch) {
289
+ // No filters: show all rows, identity mapping
290
+ this.filteredIndexes$.next(null);
291
+ this.gridService.setIndexMapping(
292
+ new Map([...Array(this.gridService.rowCount()).keys()].map(i => [i, i]))
293
+ );
294
+ return;
295
+ }
296
+
297
+ let globalSearchQuery: string | null = null;
298
+ if (this.globalSearch) {
299
+ const columns = this.getSearchColumns(this.globalSearch.options);
300
+ const caseInsensitive = this.globalSearch.options?.caseInsensitive ?? this.gridService.gridOptions.caseInsensitiveFiltering ?? false;
301
+ const searchMode = this.globalSearch.options?.searchMode ?? 'value';
302
+ globalSearchQuery = caseInsensitive ? this.globalSearch.query.toLowerCase() : this.globalSearch.query;
303
+
304
+ this.ensureSearchCache(rows, columns, caseInsensitive, searchMode);
305
+ }
306
+
307
+ // Find all matching row indexes using both filter tree and global search.
308
+ const matchingIndexes = rows
309
+ .map((row, idx) => {
310
+ const matchesTree = this.matchesFilterTree(row);
311
+ if (!matchesTree) {
312
+ return -1;
313
+ }
314
+
315
+ if (!globalSearchQuery) {
316
+ return idx;
317
+ }
318
+
319
+ const searchableText = this.searchableTextCache?.[idx] ?? '';
320
+ return searchableText.includes(globalSearchQuery) ? idx : -1;
321
+ })
322
+ .filter(idx => idx !== -1);
323
+
324
+ // Update index mapping for performance and downstream features
325
+ this.gridService.clearIndexMap();
326
+ matchingIndexes.forEach((originalIdx, filteredIdx) => {
327
+ this.gridService.setIndexMappingValue(filteredIdx, originalIdx);
328
+ });
329
+
330
+ this.filteredIndexes$.next(matchingIndexes);
331
+ }
332
+
333
+ applyFilter(rows: Row[]): Row[] {
334
+ const filteredIndexes = this.filteredIndexes$.getValue();
335
+
336
+ if (filteredIndexes === null) {
337
+ return rows;
338
+ }
339
+
340
+ this.gridService.clearIndexMap();
341
+ filteredIndexes.forEach((filteredIndex, i) => {
342
+ this.gridService.setIndexMappingValue(i, filteredIndex);
343
+ });
344
+
345
+ return filteredIndexes.map(index => rows[index]);
346
+ }
347
+ }
@@ -0,0 +1,205 @@
1
+ import type { GridService } from "../GridService";
2
+ import { Column, DisplayValue, Row } from "../types/grid";
3
+ import { format } from 'date-fns';
4
+ import { toZonedTime } from 'date-fns-tz';
5
+ import { logger } from "@open-dcp/utilities";
6
+
7
+ function formatInTimeZone(
8
+ timestamp: number | Date,
9
+ timeZone: string,
10
+ formatString: string
11
+ ): string {
12
+ const date = typeof timestamp === "number" ? new Date(timestamp) : timestamp;
13
+ const zonedDate = toZonedTime(date, timeZone);
14
+ return format(zonedDate, formatString);
15
+ }
16
+
17
+ export class FormattingService {
18
+ private readonly gridService: GridService;
19
+
20
+ constructor(gridService: GridService) {
21
+ this.gridService = gridService;
22
+ }
23
+
24
+ applyFormattingToRow(row: Row): Row {
25
+ const columns = this.gridService.getColumns();
26
+ const formattedRow: Row = { ...row }; // Clone the row to keep it immutable
27
+
28
+ if (columns && columns.length > 0) {
29
+ // Columns are ready, proceed with formatting
30
+ columns.forEach((column: Column) => {
31
+ const cellData = row[column.key] ?? null;
32
+
33
+ const rawValue = cellData && typeof cellData === 'object' && 'raw' in cellData
34
+ ? cellData.raw
35
+ : cellData;
36
+
37
+ const value = this.formatCellValue(row, column);
38
+
39
+ // Store raw and value
40
+ formattedRow[column.key] = {
41
+ raw: rawValue, // Ensure raw value stays the same
42
+ value: value, // Store transformed value for sorting/filtering
43
+ };
44
+ });
45
+ } else {
46
+ Object.keys(row).forEach(key => {
47
+ formattedRow[key] = {
48
+ raw: row[key], // Use original value as raw
49
+ value: null, // No transformation yet
50
+ };
51
+ });
52
+
53
+ //logger.warn('Columns are not available yet. Row formatting is deferred.');
54
+ }
55
+
56
+ return formattedRow;
57
+ }
58
+
59
+ private formatCustomCell(cellValue: any, row: Row, column: Column): DisplayValue {
60
+ let displayValue: string | undefined = undefined;
61
+ let renderedElement: HTMLElement | undefined = undefined;
62
+ if (typeof column.renderCell === 'function') {
63
+ try {
64
+ const functionReturn: string | HTMLElement = column.renderCell(cellValue, row);
65
+ if (functionReturn instanceof HTMLAnchorElement) {
66
+ renderedElement = functionReturn;
67
+ } else if (typeof functionReturn == "string") {
68
+ displayValue = functionReturn;
69
+ }
70
+ } catch (error) {
71
+ logger.error(`Error in custom renderCell function for column ${column.key}:`, error);
72
+ displayValue = 'Error';
73
+ }
74
+ } else {
75
+ logger.warn(`Custom column type requires a renderCell function.`);
76
+ displayValue = 'N/A';
77
+ }
78
+ return { display: displayValue, renderedElement };
79
+ }
80
+
81
+ private formatDateCell(cellValue: any, column: Column): string {
82
+ const date = new Date(cellValue);
83
+ let dateFormatString = this.gridService.displayFormatSubject.getValue().dateFormat;
84
+ if (column.type === 'datetime') {
85
+ dateFormatString += ` ${this.gridService.displayFormatSubject.getValue().timeFormat}`;
86
+ }
87
+ return formatInTimeZone(
88
+ date,
89
+ this.gridService.displayFormatSubject.getValue().timeZone,
90
+ dateFormatString
91
+ );
92
+ }
93
+
94
+ formatCellDisplay(row: Row, column: Column): DisplayValue {
95
+ const cellValue: any = row[column.key]?.value ?? null;
96
+ if (cellValue === null) {
97
+ return { display: null, renderedElement: undefined };
98
+ }
99
+
100
+ let displayValue: string | undefined = undefined;
101
+ let renderedElement: HTMLElement | undefined = undefined;
102
+
103
+ if (typeof column.transformer === 'function') {
104
+ try {
105
+ const transformed = column.transformer(cellValue);
106
+ displayValue = transformed != null ? transformed.toString() : '';
107
+ return { display: displayValue, renderedElement };
108
+ } catch (error) {
109
+ logger.error(`Error in transformer for column ${column.key}:`, error);
110
+ return { display: 'Error', renderedElement };
111
+ }
112
+ }
113
+
114
+ try {
115
+ switch (column.type) {
116
+ case 'component':
117
+ break;
118
+ case 'custom':
119
+ return this.formatCustomCell(cellValue, row, column);
120
+ case 'integer':
121
+ case 'number':
122
+ displayValue = cellValue.toString();
123
+ break;
124
+ case 'date':
125
+ case 'datetime':
126
+ displayValue = this.formatDateCell(cellValue, column);
127
+ break;
128
+ case 'duration':
129
+ displayValue = `${Math.floor(cellValue / 3600).toString().padStart(2, '0')}:${Math.floor((cellValue % 3600) / 60).toString().padStart(2, '0')}:${(cellValue % 60).toString().padStart(2, '0')}`;
130
+ break;
131
+ case 'string':
132
+ default:
133
+ displayValue = cellValue.toString();
134
+ break;
135
+ }
136
+ } catch (error) {
137
+ logger.error(`Error formatting cell for column ${column.key}:`, error);
138
+ displayValue = 'Error';
139
+ }
140
+
141
+ return { display: displayValue, renderedElement };
142
+ }
143
+
144
+ private transformCustomOrComponent(rawValue: any, column: Column): any {
145
+ if (typeof column.transformer === 'function') {
146
+ try {
147
+ return column.transformer(rawValue);
148
+ } catch (error) {
149
+ logger.error(`Error in custom transformer function for column ${column.key}:`, error);
150
+ return 'Error';
151
+ }
152
+ }
153
+ return rawValue;
154
+ }
155
+
156
+ private transformInteger(rawValue: any): number | null {
157
+ return rawValue !== null ? parseInt(rawValue, 10) : null;
158
+ }
159
+
160
+ private transformNumber(rawValue: any): number | null {
161
+ return rawValue !== null ? parseFloat(rawValue) : null;
162
+ }
163
+
164
+ private transformDate(rawValue: any): number | null {
165
+ if (rawValue !== null) {
166
+ const date = new Date(rawValue);
167
+ return isNaN(date.getTime()) ? null : date.getTime();
168
+ }
169
+ return null;
170
+ }
171
+
172
+ private transformDuration(rawValue: any): number | null {
173
+ return rawValue !== null ? parseInt(rawValue, 10) : null;
174
+ }
175
+
176
+ private transformString(rawValue: any): string | null {
177
+ return rawValue !== null ? rawValue.toString() : null;
178
+ }
179
+
180
+ formatCellValue(row: Row, column: Column): any {
181
+ // Safely retrieve the raw value
182
+ const cellData = row[column.key] ?? null;
183
+ const rawValue: any = cellData && typeof cellData === 'object' && 'raw' in cellData
184
+ ? cellData.raw
185
+ : cellData;
186
+
187
+ switch (column.type) {
188
+ case 'component':
189
+ case 'custom':
190
+ return this.transformCustomOrComponent(rawValue, column);
191
+ case 'integer':
192
+ return this.transformInteger(rawValue);
193
+ case 'number':
194
+ return this.transformNumber(rawValue);
195
+ case 'date':
196
+ case 'datetime':
197
+ return this.transformDate(rawValue);
198
+ case 'duration':
199
+ return this.transformDuration(rawValue);
200
+ case 'string':
201
+ default:
202
+ return this.transformString(rawValue);
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,81 @@
1
+ import type { GridService } from "../GridService";
2
+
3
+ export class RowHeightService {
4
+ private rowHeights: Map<number, number> = new Map();
5
+ private heightCache: number[] = [];
6
+ private readonly gridService: GridService;
7
+
8
+ constructor(gridService: GridService) {
9
+ this.gridService = gridService;
10
+ }
11
+
12
+ initRowHeights(numberOfRows: number) {
13
+ this.rowHeights = new Map(
14
+ Array.from({ length: numberOfRows }, (_, index) => [index, this.gridService.elementHeight])
15
+ );
16
+ }
17
+
18
+ getRowHeight(index: number): number {
19
+ return this.rowHeights.get(index) ?? this.gridService.elementHeight
20
+ }
21
+
22
+ getHeightCache(): number[] {
23
+ return this.heightCache;
24
+ }
25
+
26
+ updateRowHeight(index: number, height: number) {
27
+ this.rowHeights.set(index, height);
28
+ this.recalculateHeights();
29
+ }
30
+
31
+ recalculateHeights() {
32
+ let filteredRows = this.gridService.getFilterIndexes();
33
+
34
+ this.heightCache = [];
35
+ let sum = 0;
36
+ for (let i = 0; i < this.gridService.rowCount(); i++) {
37
+ let rowIndex = filteredRows ? filteredRows[i] : i;
38
+ sum += this.getRowHeight(rowIndex);
39
+ this.heightCache.push(sum);
40
+ }
41
+ }
42
+
43
+ updateRowHeights() {
44
+ let container = this.gridService.getContainer();
45
+ if (!container) return;
46
+
47
+ const rows = container.querySelectorAll('tbody > tr:not(.firstRow):not(.lastRow)'); // Adjust the selector as needed
48
+
49
+ let hasUpdated = false;
50
+
51
+ rows.forEach((row, index) => {
52
+ const rowHeight = row.getBoundingClientRect().height; // Get the actual row height
53
+ const rowIndex = index + this.gridService.startIndex;
54
+
55
+ if (this.getRowHeight(rowIndex) !== rowHeight) {
56
+ this.updateRowHeight(rowIndex, rowHeight);
57
+ hasUpdated = true;
58
+ }
59
+ });
60
+
61
+ if (hasUpdated) {
62
+ this.gridService.updateRenderedRows();
63
+ }
64
+ }
65
+
66
+ findStartIndex(scrollTop: number): number {
67
+ let index = 0;
68
+ while (index < this.heightCache.length && this.heightCache[index] < scrollTop) {
69
+ index++;
70
+ }
71
+ return Math.max(0, index);
72
+ }
73
+
74
+ findEndIndex(scrollBottom: number): number {
75
+ let index = 0;
76
+ while (index < this.heightCache.length && this.heightCache[index] < scrollBottom) {
77
+ index++;
78
+ }
79
+ return Math.min(index, this.heightCache.length - 1)
80
+ }
81
+ }
@@ -0,0 +1,71 @@
1
+ import { BehaviorSubject } from 'rxjs';
2
+
3
+ export class SelectionService {
4
+ private readonly selectedIndexes$: BehaviorSubject<Set<number>>;
5
+ private readonly multiSelect: boolean;
6
+
7
+ constructor(selections: Set<number> = new Set(), multiSelect: boolean = true) {
8
+ this.multiSelect = multiSelect;
9
+ this.selectedIndexes$ = new BehaviorSubject<Set<number>>(selections);
10
+ }
11
+
12
+ get selected$() {
13
+ return this.selectedIndexes$.asObservable();
14
+ }
15
+
16
+ selectRow(index: number) {
17
+ const selected = new Set(this.selectedIndexes$.value);
18
+ selected.add(index);
19
+ this.selectedIndexes$.next(selected);
20
+ }
21
+
22
+ deselectRow(index: number) {
23
+ const selected = new Set(this.selectedIndexes$.value);
24
+ selected.delete(index);
25
+ this.selectedIndexes$.next(selected);
26
+ }
27
+
28
+ toggleRowSelection(index: number) {
29
+ const selected = new Set(this.selectedIndexes$.value);
30
+ if (selected.has(index)) {
31
+ selected.delete(index);
32
+ } else {
33
+ if (!this.multiSelect) {
34
+ selected.clear();
35
+
36
+ }
37
+ selected.add(index);
38
+ }
39
+
40
+ this.selectedIndexes$.next(selected);
41
+ }
42
+
43
+ clearSelection() {
44
+ this.selectedIndexes$.next(new Set());
45
+ }
46
+
47
+ selectAll(indices: number[]) {
48
+ this.selectedIndexes$.next(new Set(indices));
49
+ }
50
+
51
+ deselectAll() {
52
+ this.clearSelection();
53
+ }
54
+
55
+ shiftSelect(startIndex: number, endIndex: number) {
56
+ if (!this.multiSelect) return;
57
+
58
+ const selected = new Set(this.selectedIndexes$.value);
59
+ const [start, end] = [Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)];
60
+
61
+ for (let i = start; i <= end; i++) {
62
+ selected.add(i);
63
+ }
64
+
65
+ this.selectedIndexes$.next(selected);
66
+ }
67
+
68
+ isSelected(index: number): boolean {
69
+ return this.selectedIndexes$.value.has(index);
70
+ }
71
+ }