@opencorestack/opengridx 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,354 @@
1
+ import { useMemo } from 'react';
2
+ import type { GridColDef, GridRowModel, GridRowId, GridColumnPinning, GridDetailPanelHeight } from '../../types';
3
+ import { isColumnPinned } from '../../utils/pinning';
4
+
5
+ export interface UseLayoutParams<R extends GridRowModel> {
6
+ rowHeight: number;
7
+ pagination: boolean;
8
+ paginatedUnpinnedRows: R[];
9
+ sortedUnpinnedRows: R[];
10
+ expandedRowIds: Set<GridRowId>;
11
+ getDetailPanelHeight?: (params: { row: R; id: GridRowId; rowIndex: number }) => GridDetailPanelHeight;
12
+ pinnedTopRowsLength: number;
13
+ pinnedBottomRowsLength: number;
14
+ visibleOrderedColumns: GridColDef<R>[];
15
+ pinnedColumns?: GridColumnPinning;
16
+ columnWidths: Record<string, number>;
17
+ viewportWidth: number;
18
+ checkboxSelection: boolean;
19
+ hasDetailPanel: boolean;
20
+ rowReordering: boolean;
21
+ pinCheckboxColumn: boolean;
22
+ pinExpandColumn: boolean;
23
+ autoHeight: boolean;
24
+ paginationMode: string;
25
+ isLoading: boolean;
26
+ pageSize: number;
27
+ }
28
+
29
+ export interface LayoutResult<R extends GridRowModel> {
30
+ rowHeights: number[];
31
+ cumulativeHeights: number[];
32
+ unpinnedRowsHeight: number;
33
+ pinnedTopHeight: number;
34
+ pinnedBottomHeight: number;
35
+ leftPinnedCols: (GridColDef<R> & { width: number; zIndex: number })[];
36
+ rightPinnedCols: (GridColDef<R> & { width: number; zIndex: number })[];
37
+ unpinnedColsWithWidth: (GridColDef<R> & { width: number })[];
38
+ unpinnedColWidths: number[];
39
+ unpinnedAccWidths: number[];
40
+ totalWidth: number;
41
+ leftWidth: number;
42
+ rightWidth: number;
43
+ unpinnedTotalWidth: number;
44
+ systemColumnsWidth: number;
45
+ pinnedTopRowsLength: number;
46
+ pinnedBottomRowsLength: number;
47
+ unpinnedRowsLength: number;
48
+ }
49
+
50
+ function parseWidth(width: number | string | undefined): { type: 'fixed' | 'percentage' | 'auto'; value: number } {
51
+ if (width === undefined) return { type: 'auto', value: 0 };
52
+ if (typeof width === 'number') return { type: 'fixed', value: width };
53
+ if (typeof width === 'string') {
54
+ if (width.toLowerCase() === 'auto') return { type: 'auto', value: 0 };
55
+ if (width.endsWith('%')) {
56
+ const percentage = parseFloat(width);
57
+ return { type: 'percentage', value: percentage };
58
+ }
59
+ const floatVal = parseFloat(width);
60
+ if (!isNaN(floatVal)) return { type: 'fixed', value: floatVal };
61
+ }
62
+ return { type: 'fixed', value: 100 };
63
+ }
64
+
65
+ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): LayoutResult<R> {
66
+ const {
67
+ rowHeight,
68
+ pagination,
69
+ paginatedUnpinnedRows,
70
+ sortedUnpinnedRows,
71
+ expandedRowIds,
72
+ getDetailPanelHeight,
73
+ pinnedTopRowsLength,
74
+ pinnedBottomRowsLength,
75
+ visibleOrderedColumns,
76
+ pinnedColumns,
77
+ columnWidths,
78
+ viewportWidth,
79
+ checkboxSelection,
80
+ hasDetailPanel,
81
+ rowReordering,
82
+ pinCheckboxColumn,
83
+ pinExpandColumn,
84
+ autoHeight,
85
+ paginationMode,
86
+ isLoading,
87
+ pageSize,
88
+ } = params;
89
+
90
+ return useMemo<LayoutResult<R>>(() => {
91
+ const unpinnedRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
92
+
93
+ const rowHeights = unpinnedRows.map((row, index) => {
94
+ let height = rowHeight;
95
+ if (expandedRowIds.has(row.id)) {
96
+ const detailHeight = getDetailPanelHeight?.({ row, id: row.id, rowIndex: pinnedTopRowsLength + index }) ?? 200;
97
+ height += typeof detailHeight === 'number' ? detailHeight : parseInt(String(detailHeight), 10) || 200;
98
+ }
99
+ return height;
100
+ });
101
+
102
+ const cumulativeHeights = rowHeights.reduce((acc, height, index) => {
103
+ acc.push((acc[index - 1] || 0) + height);
104
+ return acc;
105
+ }, [] as number[]);
106
+
107
+ const skeletonCount = (paginationMode === 'infinite' && isLoading && unpinnedRows.length > 0)
108
+ ? Math.min(pageSize, 20)
109
+ : 0;
110
+ const unpinnedRowsHeight = (cumulativeHeights[cumulativeHeights.length - 1] || 0) + skeletonCount * rowHeight;
111
+ const pinnedTopHeight = pinnedTopRowsLength * rowHeight;
112
+ const pinnedBottomHeight = pinnedBottomRowsLength * rowHeight;
113
+
114
+ const rawLeftPinnedCols: GridColDef<R>[] = [];
115
+ const rawRightPinnedCols: GridColDef<R>[] = [];
116
+ const unpinnedCols: GridColDef<R>[] = [];
117
+ const unpinnedColWidths: number[] = [];
118
+
119
+ visibleOrderedColumns.forEach(col => {
120
+ const pinned = isColumnPinned(col.field, pinnedColumns);
121
+ if (pinned === 'left') {
122
+ rawLeftPinnedCols.push(col);
123
+ } else if (pinned === 'right') {
124
+ rawRightPinnedCols.push(col);
125
+ } else {
126
+ unpinnedCols.push(col);
127
+ }
128
+ });
129
+
130
+ const systemColumnsWidth = (checkboxSelection ? 48 : 0) + (hasDetailPanel ? 48 : 0) + (rowReordering ? 48 : 0);
131
+
132
+ const getColWidth = (c: GridColDef<R>) => columnWidths[c.field] ?? c.width;
133
+
134
+ const leftWidth = rawLeftPinnedCols.reduce((sum, c) => {
135
+ const parsed = parseWidth(getColWidth(c));
136
+ return sum + (parsed.type === 'fixed' ? parsed.value : 100);
137
+ }, 0);
138
+ const rightWidth = rawRightPinnedCols.reduce((sum, c) => {
139
+ const parsed = parseWidth(getColWidth(c));
140
+ return sum + (parsed.type === 'fixed' ? parsed.value : 100);
141
+ }, 0);
142
+
143
+ const naturalFlexWidth = unpinnedCols.reduce((sum, col) => {
144
+ const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
145
+ if (!hasManualWidth && col.flex && col.flex > 0) {
146
+ const minWidth = col.minWidth ?? 150;
147
+ const w = typeof col.width === 'number' ? col.width : 150;
148
+ return sum + (minWidth || w);
149
+ }
150
+ const parsed = parseWidth(getColWidth(col));
151
+ if (parsed.type === 'fixed') return sum + parsed.value;
152
+ if (parsed.type === 'percentage') return sum + 100;
153
+ return sum + (col.minWidth ?? 150);
154
+ }, 0);
155
+
156
+ const availableWidth = Math.max(
157
+ viewportWidth - systemColumnsWidth - leftWidth - rightWidth,
158
+ naturalFlexWidth
159
+ );
160
+
161
+ const fixedWidthCols: Array<{ col: GridColDef<R>; width: number }> = [];
162
+ const percentageCols: Array<{ col: GridColDef<R>; percentage: number }> = [];
163
+ const flexCols: Array<{ col: GridColDef<R>; flex: number }> = [];
164
+
165
+ unpinnedCols.forEach(col => {
166
+ const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
167
+ const parsed = parseWidth(getColWidth(col));
168
+
169
+ if (!hasManualWidth && col.flex && col.flex > 0) {
170
+ flexCols.push({ col, flex: col.flex });
171
+ return;
172
+ }
173
+
174
+ if (parsed.type === 'fixed') {
175
+ fixedWidthCols.push({ col, width: parsed.value });
176
+ } else if (parsed.type === 'percentage') {
177
+ percentageCols.push({ col, percentage: parsed.value });
178
+ } else {
179
+ flexCols.push({ col, flex: 1 });
180
+ }
181
+ });
182
+
183
+ const fixedWidth = fixedWidthCols.reduce((sum, { width }) => sum + width, 0);
184
+ let remainingSpaceForFlex = Math.max(0, availableWidth - fixedWidth);
185
+
186
+ const percentageWidthMap = new Map<string, number>();
187
+ percentageCols.forEach(({ col, percentage }) => {
188
+ const calculatedWidth = (percentage / 100) * remainingSpaceForFlex;
189
+ percentageWidthMap.set(col.field, calculatedWidth);
190
+ remainingSpaceForFlex -= calculatedWidth;
191
+ });
192
+ remainingSpaceForFlex = Math.max(0, remainingSpaceForFlex);
193
+
194
+ const finalFlexWidths = new Map<string, number>();
195
+ const flexItems = flexCols.map(f => ({
196
+ ...f,
197
+ minWidth: f.col.minWidth ?? 50,
198
+ maxWidth: f.col.maxWidth ?? Infinity,
199
+ frozen: false,
200
+ computedWidth: 0
201
+ }));
202
+
203
+ const solveFlexAllocation = () => {
204
+ let iterations = 0;
205
+ const maxIterations = flexItems.length * 2;
206
+
207
+ while (iterations < maxIterations) {
208
+ iterations++;
209
+ const unfrozen = flexItems.filter(f => !f.frozen);
210
+ if (unfrozen.length === 0) break;
211
+
212
+ const unfrozenFlexTotal = unfrozen.reduce((sum, f) => sum + f.flex, 0);
213
+ const frozenWidthTotal = flexItems.reduce((sum, f) => f.frozen ? sum + f.computedWidth : sum, 0);
214
+ const currentFreeSpace = Math.max(0, remainingSpaceForFlex - frozenWidthTotal);
215
+
216
+ if (unfrozenFlexTotal <= 0) {
217
+ unfrozen.forEach(f => { f.computedWidth = f.minWidth; f.frozen = true; });
218
+ break;
219
+ }
220
+
221
+ const pixelsPerFlex = currentFreeSpace / unfrozenFlexTotal;
222
+ let totalViolation = 0;
223
+ const minViolators: typeof flexItems = [];
224
+ const maxViolators: typeof flexItems = [];
225
+
226
+ unfrozen.forEach(f => {
227
+ const rawWidth = pixelsPerFlex * f.flex;
228
+ if (rawWidth < f.minWidth) {
229
+ totalViolation += f.minWidth - rawWidth;
230
+ minViolators.push(f);
231
+ } else if (rawWidth > f.maxWidth) {
232
+ totalViolation += f.maxWidth - rawWidth;
233
+ maxViolators.push(f);
234
+ } else {
235
+ f.computedWidth = rawWidth;
236
+ }
237
+ });
238
+
239
+ if (minViolators.length === 0 && maxViolators.length === 0) {
240
+ unfrozen.forEach(f => f.frozen = true);
241
+ break;
242
+ }
243
+
244
+ if (totalViolation > 0) {
245
+ minViolators.forEach(f => { f.computedWidth = f.minWidth; f.frozen = true; });
246
+ } else if (totalViolation < 0) {
247
+ maxViolators.forEach(f => { f.computedWidth = f.maxWidth; f.frozen = true; });
248
+ } else {
249
+ minViolators.forEach(f => { f.computedWidth = f.minWidth; f.frozen = true; });
250
+ maxViolators.forEach(f => { f.computedWidth = f.maxWidth; f.frozen = true; });
251
+ }
252
+ }
253
+ };
254
+
255
+ solveFlexAllocation();
256
+ flexItems.forEach(f => finalFlexWidths.set(f.col.field, f.computedWidth));
257
+
258
+ const computedWidthMap = new Map<string, number>();
259
+ unpinnedCols.forEach(col => {
260
+ let computedWidth: number;
261
+ if (finalFlexWidths.has(col.field)) {
262
+ computedWidth = finalFlexWidths.get(col.field)!;
263
+ } else {
264
+ const fixedCol = fixedWidthCols.find(f => f.col.field === col.field);
265
+ const pctCol = percentageCols.find(p => p.col.field === col.field);
266
+ if (fixedCol) {
267
+ computedWidth = fixedCol.width;
268
+ } else if (pctCol) {
269
+ const rawPctWidth = percentageWidthMap.get(col.field) || 0;
270
+ computedWidth = Math.max(col.minWidth ?? 50, rawPctWidth || 100);
271
+ } else {
272
+ computedWidth = 100;
273
+ }
274
+ }
275
+ unpinnedColWidths.push(computedWidth);
276
+ computedWidthMap.set(col.field, computedWidth);
277
+ });
278
+
279
+ const unpinnedColsWithWidth = unpinnedCols.map(col => {
280
+ const flexItem = flexItems.find(f => f.col.field === col.field);
281
+ return {
282
+ ...col,
283
+ width: computedWidthMap.get(col.field) ?? (typeof col.width === 'number' ? col.width : 100),
284
+ flex: flexItem ? flexItem.flex : col.flex
285
+ };
286
+ });
287
+
288
+ const baseLeftZ = 11;
289
+ const leftPinnedCols = rawLeftPinnedCols.map((col, i) => {
290
+ const parsed = parseWidth(getColWidth(col));
291
+ return { ...col, width: parsed.type === 'fixed' ? parsed.value : 100, zIndex: baseLeftZ + i };
292
+ });
293
+
294
+ const baseRightZ = 11;
295
+ const rightPinnedCols = rawRightPinnedCols.map((col, i) => {
296
+ const parsed = parseWidth(getColWidth(col));
297
+ return {
298
+ ...col,
299
+ width: parsed.type === 'fixed' ? parsed.value : 100,
300
+ zIndex: baseRightZ + (rawRightPinnedCols.length - 1 - i)
301
+ };
302
+ });
303
+
304
+ const unpinnedTotalWidth = unpinnedColWidths.reduce((sum, w) => sum + w, 0);
305
+ const totalWidth = leftWidth + unpinnedTotalWidth + rightWidth + systemColumnsWidth;
306
+ const unpinnedAccWidths = unpinnedColWidths.reduce((acc, w, i) => {
307
+ acc.push((acc[i - 1] || 0) + w);
308
+ return acc;
309
+ }, [] as number[]);
310
+
311
+ return {
312
+ rowHeights,
313
+ cumulativeHeights,
314
+ unpinnedRowsHeight,
315
+ pinnedTopHeight,
316
+ pinnedBottomHeight,
317
+ leftPinnedCols,
318
+ rightPinnedCols,
319
+ unpinnedColsWithWidth,
320
+ unpinnedColWidths,
321
+ unpinnedAccWidths,
322
+ totalWidth,
323
+ leftWidth,
324
+ rightWidth,
325
+ unpinnedTotalWidth,
326
+ systemColumnsWidth,
327
+ pinnedTopRowsLength,
328
+ pinnedBottomRowsLength,
329
+ unpinnedRowsLength: unpinnedRows.length,
330
+ };
331
+ }, [
332
+ rowHeight,
333
+ pagination,
334
+ paginatedUnpinnedRows,
335
+ sortedUnpinnedRows,
336
+ expandedRowIds,
337
+ getDetailPanelHeight,
338
+ pinnedTopRowsLength,
339
+ pinnedBottomRowsLength,
340
+ visibleOrderedColumns,
341
+ pinnedColumns,
342
+ columnWidths,
343
+ viewportWidth,
344
+ checkboxSelection,
345
+ hasDetailPanel,
346
+ rowReordering,
347
+ pinCheckboxColumn,
348
+ pinExpandColumn,
349
+ autoHeight,
350
+ paginationMode,
351
+ isLoading,
352
+ pageSize,
353
+ ]);
354
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useAggregation, formatAggregationValue } from './useAggregation';
4
+
5
+ const ROWS = [
6
+ { id: 1, salary: 50000, dept: 'Eng', active: true },
7
+ { id: 2, salary: 60000, dept: 'Eng', active: false },
8
+ { id: 3, salary: 70000, dept: 'HR', active: true },
9
+ { id: 4, salary: null, dept: 'HR', active: true },
10
+ { id: 5, salary: 80000, dept: 'Eng', active: null },
11
+ ];
12
+
13
+ describe('formatAggregationValue', () => {
14
+ it('returns em-dash for null', () => {
15
+ expect(formatAggregationValue(null, 'sum')).toBe('—');
16
+ });
17
+ it('formats avg with up to 2 decimal places', () => {
18
+ expect(formatAggregationValue(1234.5678, 'avg')).toBe('1,234.57');
19
+ });
20
+ it('formats non-avg numbers without decimals', () => {
21
+ expect(formatAggregationValue(1234567, 'sum')).toBe('1,234,567');
22
+ });
23
+ it('converts non-numeric to string', () => {
24
+ expect(formatAggregationValue('Engineering', 'unique')).toBe('Engineering');
25
+ });
26
+ });
27
+
28
+ describe('useAggregation — client-side', () => {
29
+ it('returns empty result when no aggregation model', () => {
30
+ const { result } = renderHook(() =>
31
+ useAggregation({ rows: ROWS, aggregationModel: {}, isServerSide: false })
32
+ );
33
+ expect(result.current.aggregationResult).toEqual({});
34
+ });
35
+
36
+ it('computes sum correctly, skipping nulls', () => {
37
+ const { result } = renderHook(() =>
38
+ useAggregation({
39
+ rows: ROWS,
40
+ aggregationModel: { salary: 'sum' },
41
+ isServerSide: false,
42
+ })
43
+ );
44
+ expect(result.current.aggregationResult.salary).toBe(260000);
45
+ });
46
+
47
+ it('computes avg correctly', () => {
48
+ const { result } = renderHook(() =>
49
+ useAggregation({
50
+ rows: ROWS,
51
+ aggregationModel: { salary: 'avg' },
52
+ isServerSide: false,
53
+ })
54
+ );
55
+ expect(result.current.aggregationResult.salary).toBeCloseTo(65000);
56
+ });
57
+
58
+ it('computes count of non-null values', () => {
59
+ const { result } = renderHook(() =>
60
+ useAggregation({
61
+ rows: ROWS,
62
+ aggregationModel: { salary: 'count' },
63
+ isServerSide: false,
64
+ })
65
+ );
66
+ expect(result.current.aggregationResult.salary).toBe(4);
67
+ });
68
+
69
+ it('computes min and max', () => {
70
+ const { result } = renderHook(() =>
71
+ useAggregation({
72
+ rows: ROWS,
73
+ aggregationModel: { salary: 'min' },
74
+ isServerSide: false,
75
+ })
76
+ );
77
+ expect(result.current.aggregationResult.salary).toBe(50000);
78
+
79
+ const { result: r2 } = renderHook(() =>
80
+ useAggregation({
81
+ rows: ROWS,
82
+ aggregationModel: { salary: 'max' },
83
+ isServerSide: false,
84
+ })
85
+ );
86
+ expect(r2.current.aggregationResult.salary).toBe(80000);
87
+ });
88
+
89
+ it('computes unique count', () => {
90
+ const { result } = renderHook(() =>
91
+ useAggregation({
92
+ rows: ROWS,
93
+ aggregationModel: { dept: 'unique' },
94
+ isServerSide: false,
95
+ })
96
+ );
97
+ expect(result.current.aggregationResult.dept).toBe(2);
98
+ });
99
+
100
+ it('handles unknown function gracefully — skips the field', () => {
101
+ const { result } = renderHook(() =>
102
+ useAggregation({
103
+ rows: ROWS,
104
+ aggregationModel: { salary: 'median' as 'sum' },
105
+ isServerSide: false,
106
+ })
107
+ );
108
+ expect(result.current.aggregationResult.salary).toBeUndefined();
109
+ });
110
+
111
+ it('returns {} and does not mutate state when isServerSide=true', () => {
112
+ const { result } = renderHook(() =>
113
+ useAggregation({
114
+ rows: ROWS,
115
+ aggregationModel: { salary: 'sum' },
116
+ isServerSide: true,
117
+ })
118
+ );
119
+ // No dataSource → serverResult stays {}
120
+ expect(result.current.aggregationResult).toEqual({});
121
+ });
122
+
123
+ it('returns serverAggregationResults when provided', () => {
124
+ const serverResults = { salary: 999999 };
125
+ const { result } = renderHook(() =>
126
+ useAggregation({
127
+ rows: ROWS,
128
+ aggregationModel: { salary: 'sum' },
129
+ isServerSide: true,
130
+ serverAggregationResults: serverResults,
131
+ })
132
+ );
133
+ expect(result.current.aggregationResult).toEqual(serverResults);
134
+ });
135
+ });
@@ -34,7 +34,7 @@ async function writeToClipboard(text: string): Promise<void> {
34
34
  let fallbackSuccess = false;
35
35
  try {
36
36
  fallbackSuccess = copyTextSynchronous(text);
37
- } catch (e) {
37
+ } catch {
38
38
  // Ignore fallback errors
39
39
  }
40
40
 
@@ -108,7 +108,7 @@ export function useGridDataSource<R extends GridRowModel>(params: UseGridDataSou
108
108
  if (response.rowCount !== undefined) {
109
109
  setRowCount(response.rowCount);
110
110
  } else if (paginationMode === 'infinite') {
111
-
111
+ // rowCount is not required for infinite scroll — the grid uses appendRows
112
112
  }
113
113
 
114
114
  prevParamsRef.current = {
@@ -47,7 +47,7 @@ function writeToStorage(
47
47
  : state;
48
48
  storage!.setItem(key, JSON.stringify(toWrite));
49
49
  } catch {
50
-
50
+ // swallow localStorage write errors (private browsing, quota exceeded)
51
51
  }
52
52
  }
53
53
 
@@ -119,7 +119,7 @@ export function exportToExcel<R extends GridRowModel>(
119
119
  options: ExcelExportOptions = {}
120
120
  ): void {
121
121
  const {
122
- fileName = 'export.xlsx',
122
+ fileName = 'export.xls',
123
123
  sheetName = 'Sheet1',
124
124
  includeHeaders = true,
125
125
  selectedRows