@object-ui/plugin-grid 3.3.0 → 3.3.2
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.
- package/CHANGELOG.md +23 -0
- package/README.md +21 -1
- package/dist/index.js +631 -599
- package/dist/index.umd.cjs +8 -8
- package/package.json +44 -12
- package/.turbo/turbo-build.log +0 -32
- package/src/FormulaBar.tsx +0 -151
- package/src/GroupRow.tsx +0 -69
- package/src/ImportWizard.tsx +0 -412
- package/src/InlineEditing.tsx +0 -235
- package/src/ListColumnExtensions.test.tsx +0 -373
- package/src/ListColumnSchema.test.ts +0 -88
- package/src/ObjectGrid.EdgeCases.stories.tsx +0 -147
- package/src/ObjectGrid.msw.test.tsx +0 -130
- package/src/ObjectGrid.stories.tsx +0 -139
- package/src/ObjectGrid.tsx +0 -1598
- package/src/SplitPaneGrid.tsx +0 -120
- package/src/VirtualGrid.tsx +0 -183
- package/src/__tests__/GroupRow.test.tsx +0 -206
- package/src/__tests__/ImportPreview.test.tsx +0 -171
- package/src/__tests__/InlineEditing.test.tsx +0 -360
- package/src/__tests__/VirtualGrid.test.tsx +0 -438
- package/src/__tests__/accessibility.test.tsx +0 -254
- package/src/__tests__/accessorKey-inference.test.tsx +0 -132
- package/src/__tests__/airtable-style.test.tsx +0 -508
- package/src/__tests__/column-features.test.tsx +0 -490
- package/src/__tests__/grid-export.test.tsx +0 -121
- package/src/__tests__/mobile-card-view.test.tsx +0 -355
- package/src/__tests__/objectdef-enrichment.test.tsx +0 -566
- package/src/__tests__/performance-benchmark.test.tsx +0 -182
- package/src/__tests__/phase11-features.test.tsx +0 -418
- package/src/__tests__/row-bulk-actions.test.tsx +0 -413
- package/src/__tests__/row-height.test.tsx +0 -160
- package/src/__tests__/useGroupedData.test.ts +0 -165
- package/src/__tests__/view-states.test.tsx +0 -203
- package/src/components/BulkActionBar.tsx +0 -66
- package/src/components/RowActionMenu.tsx +0 -91
- package/src/index.test.tsx +0 -29
- package/src/index.tsx +0 -99
- package/src/useCellClipboard.ts +0 -136
- package/src/useColumnSummary.ts +0 -128
- package/src/useGradientColor.ts +0 -103
- package/src/useGroupReorder.ts +0 -123
- package/src/useGroupedData.ts +0 -187
- package/src/useRowColor.ts +0 -74
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -58
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
package/src/useCellClipboard.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
10
|
-
|
|
11
|
-
/** A cell range described by start/end row and column indices. */
|
|
12
|
-
export interface CellRange {
|
|
13
|
-
startRow: number;
|
|
14
|
-
startCol: number;
|
|
15
|
-
endRow: number;
|
|
16
|
-
endCol: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface UseCellClipboardOptions {
|
|
20
|
-
/** Full data rows currently displayed in the grid. */
|
|
21
|
-
data: Record<string, any>[];
|
|
22
|
-
/** Ordered column keys matching the grid's visible columns. */
|
|
23
|
-
columns: string[];
|
|
24
|
-
/** Callback invoked when pasted values should be applied. */
|
|
25
|
-
onPaste?: (changes: { rowIndex: number; field: string; value: string }[]) => void;
|
|
26
|
-
/** Whether clipboard interaction is enabled. */
|
|
27
|
-
enabled?: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface UseCellClipboardResult {
|
|
31
|
-
/** Currently selected cell range (null when nothing is selected). */
|
|
32
|
-
selectedRange: CellRange | null;
|
|
33
|
-
/** Programmatically update the selected range. */
|
|
34
|
-
setSelectedRange: (range: CellRange | null) => void;
|
|
35
|
-
/** Copy handler – reads selected range and writes tab-separated text to clipboard. */
|
|
36
|
-
onCopy: () => void;
|
|
37
|
-
/** Paste handler – reads tab-separated text from clipboard and emits changes. */
|
|
38
|
-
onPaste: () => void;
|
|
39
|
-
/** Keyboard handler to attach to the grid container element. */
|
|
40
|
-
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Normalise a CellRange so that start ≤ end for both axes.
|
|
45
|
-
*/
|
|
46
|
-
function normaliseRange(range: CellRange): CellRange {
|
|
47
|
-
return {
|
|
48
|
-
startRow: Math.min(range.startRow, range.endRow),
|
|
49
|
-
startCol: Math.min(range.startCol, range.endCol),
|
|
50
|
-
endRow: Math.max(range.startRow, range.endRow),
|
|
51
|
-
endCol: Math.max(range.startCol, range.endCol),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Hook for single-cell and multi-cell copy/paste with Excel-compatible
|
|
57
|
-
* tab-separated format.
|
|
58
|
-
*
|
|
59
|
-
* Attach `onKeyDown` to the grid container to handle Ctrl+C / Ctrl+V.
|
|
60
|
-
*/
|
|
61
|
-
export function useCellClipboard({
|
|
62
|
-
data,
|
|
63
|
-
columns,
|
|
64
|
-
onPaste: onPasteCallback,
|
|
65
|
-
enabled = true,
|
|
66
|
-
}: UseCellClipboardOptions): UseCellClipboardResult {
|
|
67
|
-
const [selectedRange, setSelectedRange] = useState<CellRange | null>(null);
|
|
68
|
-
|
|
69
|
-
const onCopy = useCallback(() => {
|
|
70
|
-
if (!enabled || !selectedRange) return;
|
|
71
|
-
const { startRow, startCol, endRow, endCol } = normaliseRange(selectedRange);
|
|
72
|
-
const lines: string[] = [];
|
|
73
|
-
|
|
74
|
-
for (let r = startRow; r <= endRow; r++) {
|
|
75
|
-
const row = data[r];
|
|
76
|
-
if (!row) continue;
|
|
77
|
-
const cells: string[] = [];
|
|
78
|
-
for (let c = startCol; c <= endCol; c++) {
|
|
79
|
-
const field = columns[c];
|
|
80
|
-
cells.push(field ? String(row[field] ?? '') : '');
|
|
81
|
-
}
|
|
82
|
-
lines.push(cells.join('\t'));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const text = lines.join('\n');
|
|
86
|
-
void navigator.clipboard.writeText(text);
|
|
87
|
-
}, [enabled, selectedRange, data, columns]);
|
|
88
|
-
|
|
89
|
-
const onPaste = useCallback(() => {
|
|
90
|
-
if (!enabled || !selectedRange || !onPasteCallback) return;
|
|
91
|
-
const { startRow, startCol } = normaliseRange(selectedRange);
|
|
92
|
-
|
|
93
|
-
void navigator.clipboard.readText().then((text) => {
|
|
94
|
-
const changes: { rowIndex: number; field: string; value: string }[] = [];
|
|
95
|
-
const lines = text.split('\n');
|
|
96
|
-
|
|
97
|
-
for (let r = 0; r < lines.length; r++) {
|
|
98
|
-
const cells = lines[r].split('\t');
|
|
99
|
-
for (let c = 0; c < cells.length; c++) {
|
|
100
|
-
const rowIndex = startRow + r;
|
|
101
|
-
const colIndex = startCol + c;
|
|
102
|
-
const field = columns[colIndex];
|
|
103
|
-
if (field && rowIndex < data.length) {
|
|
104
|
-
changes.push({ rowIndex, field, value: cells[c] });
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (changes.length > 0) {
|
|
110
|
-
onPasteCallback(changes);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
}, [enabled, selectedRange, columns, data.length, onPasteCallback]);
|
|
114
|
-
|
|
115
|
-
const onKeyDown = useCallback(
|
|
116
|
-
(e: React.KeyboardEvent) => {
|
|
117
|
-
if (!enabled) return;
|
|
118
|
-
const mod = e.metaKey || e.ctrlKey;
|
|
119
|
-
if (mod && e.key === 'c') {
|
|
120
|
-
e.preventDefault();
|
|
121
|
-
onCopy();
|
|
122
|
-
} else if (mod && e.key === 'v') {
|
|
123
|
-
e.preventDefault();
|
|
124
|
-
onPaste();
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
[enabled, onCopy, onPaste],
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// Clear selection when clipboard features are disabled.
|
|
131
|
-
useEffect(() => {
|
|
132
|
-
if (!enabled) setSelectedRange(null);
|
|
133
|
-
}, [enabled]);
|
|
134
|
-
|
|
135
|
-
return { selectedRange, setSelectedRange, onCopy, onPaste, onKeyDown };
|
|
136
|
-
}
|
package/src/useColumnSummary.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useMemo } from 'react';
|
|
10
|
-
import type { ListColumn } from '@object-ui/types';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Summary configuration for a column.
|
|
14
|
-
* Can be a string shorthand (e.g. 'sum') or a full config object.
|
|
15
|
-
*/
|
|
16
|
-
export type ColumnSummaryConfig = string | { type: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string };
|
|
17
|
-
|
|
18
|
-
export interface ColumnSummaryResult {
|
|
19
|
-
field: string;
|
|
20
|
-
value: number | null;
|
|
21
|
-
label: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Normalize summary config from string or object to a standard shape.
|
|
26
|
-
*/
|
|
27
|
-
function normalizeSummary(summary: ColumnSummaryConfig): { type: string; field?: string } {
|
|
28
|
-
if (typeof summary === 'string') {
|
|
29
|
-
return { type: summary };
|
|
30
|
-
}
|
|
31
|
-
return summary;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Compute a single aggregation over data values.
|
|
36
|
-
*/
|
|
37
|
-
function computeAggregation(type: string, values: number[]): number | null {
|
|
38
|
-
if (values.length === 0) return null;
|
|
39
|
-
|
|
40
|
-
switch (type) {
|
|
41
|
-
case 'count':
|
|
42
|
-
return values.length;
|
|
43
|
-
case 'sum':
|
|
44
|
-
return values.reduce((a, b) => a + b, 0);
|
|
45
|
-
case 'avg':
|
|
46
|
-
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
47
|
-
case 'min':
|
|
48
|
-
return Math.min(...values);
|
|
49
|
-
case 'max':
|
|
50
|
-
return Math.max(...values);
|
|
51
|
-
default:
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Format a summary value for display.
|
|
58
|
-
*/
|
|
59
|
-
function formatSummaryLabel(type: string, value: number | null): string {
|
|
60
|
-
if (value === null) return '';
|
|
61
|
-
const typeLabels: Record<string, string> = {
|
|
62
|
-
count: 'Count',
|
|
63
|
-
sum: 'Sum',
|
|
64
|
-
avg: 'Avg',
|
|
65
|
-
min: 'Min',
|
|
66
|
-
max: 'Max',
|
|
67
|
-
};
|
|
68
|
-
const label = typeLabels[type] || type;
|
|
69
|
-
const formatted = type === 'avg'
|
|
70
|
-
? value.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
71
|
-
: value.toLocaleString();
|
|
72
|
-
return `${label}: ${formatted}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Hook to compute column summary/aggregation values.
|
|
77
|
-
*
|
|
78
|
-
* @param columns - Column definitions (may include `summary` config)
|
|
79
|
-
* @param data - Row data array
|
|
80
|
-
* @returns Map of field name to summary result, and a flag if any summaries exist
|
|
81
|
-
*/
|
|
82
|
-
export function useColumnSummary(
|
|
83
|
-
columns: ListColumn[] | undefined,
|
|
84
|
-
data: any[]
|
|
85
|
-
): { summaries: Map<string, ColumnSummaryResult>; hasSummary: boolean } {
|
|
86
|
-
return useMemo(() => {
|
|
87
|
-
const summaries = new Map<string, ColumnSummaryResult>();
|
|
88
|
-
|
|
89
|
-
if (!columns || columns.length === 0 || data.length === 0) {
|
|
90
|
-
return { summaries, hasSummary: false };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
for (const col of columns) {
|
|
94
|
-
if (!col.summary) continue;
|
|
95
|
-
|
|
96
|
-
const config = normalizeSummary(col.summary as ColumnSummaryConfig);
|
|
97
|
-
const targetField = config.field || col.field;
|
|
98
|
-
|
|
99
|
-
// Extract numeric values from data
|
|
100
|
-
const values: number[] = [];
|
|
101
|
-
for (const row of data) {
|
|
102
|
-
const v = row[targetField];
|
|
103
|
-
if (v != null && typeof v === 'number' && !isNaN(v)) {
|
|
104
|
-
values.push(v);
|
|
105
|
-
} else if (v != null && typeof v === 'string') {
|
|
106
|
-
const parsed = parseFloat(v);
|
|
107
|
-
if (!isNaN(parsed)) values.push(parsed);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// For 'count', count all non-null values (not just numeric)
|
|
112
|
-
let result: number | null;
|
|
113
|
-
if (config.type === 'count') {
|
|
114
|
-
const count = data.filter(row => row[targetField] != null && row[targetField] !== '').length;
|
|
115
|
-
result = count > 0 ? count : null;
|
|
116
|
-
} else {
|
|
117
|
-
result = computeAggregation(config.type, values);
|
|
118
|
-
}
|
|
119
|
-
summaries.set(col.field, {
|
|
120
|
-
field: col.field,
|
|
121
|
-
value: result,
|
|
122
|
-
label: formatSummaryLabel(config.type, result),
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return { summaries, hasSummary: summaries.size > 0 };
|
|
127
|
-
}, [columns, data]);
|
|
128
|
-
}
|
package/src/useGradientColor.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useMemo, useCallback } from 'react';
|
|
10
|
-
|
|
11
|
-
/** A single stop in a colour gradient. */
|
|
12
|
-
export interface GradientStop {
|
|
13
|
-
/** Position between 0 and 1. */
|
|
14
|
-
position: number;
|
|
15
|
-
/** Tailwind background class applied at this stop (e.g. "bg-green-100"). */
|
|
16
|
-
className: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface UseGradientColorOptions {
|
|
20
|
-
/** The numeric field to evaluate on each row. */
|
|
21
|
-
field: string;
|
|
22
|
-
/** Flat data array used to derive min/max when not provided. */
|
|
23
|
-
data: Record<string, any>[];
|
|
24
|
-
/** Optional explicit minimum value. */
|
|
25
|
-
min?: number;
|
|
26
|
-
/** Optional explicit maximum value. */
|
|
27
|
-
max?: number;
|
|
28
|
-
/**
|
|
29
|
-
* Ordered gradient stops (position 0 → 1).
|
|
30
|
-
* When omitted a default green→yellow→red palette is used.
|
|
31
|
-
*/
|
|
32
|
-
stops?: GradientStop[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Default three-stop gradient: green → yellow → red. */
|
|
36
|
-
const DEFAULT_STOPS: GradientStop[] = [
|
|
37
|
-
{ position: 0, className: 'bg-green-100' },
|
|
38
|
-
{ position: 0.5, className: 'bg-yellow-100' },
|
|
39
|
-
{ position: 1, className: 'bg-red-100' },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Derive min and max numeric values for `field` across `data`.
|
|
44
|
-
*/
|
|
45
|
-
function deriveRange(data: Record<string, any>[], field: string): [number, number] {
|
|
46
|
-
let lo = Infinity;
|
|
47
|
-
let hi = -Infinity;
|
|
48
|
-
for (const row of data) {
|
|
49
|
-
const v = Number(row[field]);
|
|
50
|
-
if (!Number.isFinite(v)) continue;
|
|
51
|
-
if (v < lo) lo = v;
|
|
52
|
-
if (v > hi) hi = v;
|
|
53
|
-
}
|
|
54
|
-
if (!Number.isFinite(lo)) return [0, 0];
|
|
55
|
-
return [lo, hi];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Pick the closest gradient stop class for a normalised position (0‑1).
|
|
60
|
-
*/
|
|
61
|
-
function pickStopClass(t: number, stops: GradientStop[]): string {
|
|
62
|
-
if (stops.length === 0) return '';
|
|
63
|
-
if (stops.length === 1) return stops[0].className;
|
|
64
|
-
|
|
65
|
-
let best = stops[0];
|
|
66
|
-
let bestDist = Math.abs(t - best.position);
|
|
67
|
-
|
|
68
|
-
for (let i = 1; i < stops.length; i++) {
|
|
69
|
-
const dist = Math.abs(t - stops[i].position);
|
|
70
|
-
if (dist < bestDist) {
|
|
71
|
-
best = stops[i];
|
|
72
|
-
bestDist = dist;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return best.className;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Hook that returns a row → Tailwind class resolver based on a numeric
|
|
80
|
-
* field's value mapped onto a configurable colour gradient.
|
|
81
|
-
*
|
|
82
|
-
* @param options - gradient configuration
|
|
83
|
-
* @returns `(row) => className | undefined`
|
|
84
|
-
*/
|
|
85
|
-
export function useGradientColor(options: UseGradientColorOptions) {
|
|
86
|
-
const { field, data, min: minProp, max: maxProp, stops = DEFAULT_STOPS } = options;
|
|
87
|
-
|
|
88
|
-
const [derivedMin, derivedMax] = useMemo(() => deriveRange(data, field), [data, field]);
|
|
89
|
-
const min = minProp ?? derivedMin;
|
|
90
|
-
const max = maxProp ?? derivedMax;
|
|
91
|
-
|
|
92
|
-
return useCallback(
|
|
93
|
-
(row: Record<string, any>): string | undefined => {
|
|
94
|
-
const v = Number(row[field]);
|
|
95
|
-
if (!Number.isFinite(v)) return undefined;
|
|
96
|
-
if (max === min) return stops.length > 0 ? stops[0].className : undefined;
|
|
97
|
-
|
|
98
|
-
const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
|
99
|
-
return pickStopClass(t, stops);
|
|
100
|
-
},
|
|
101
|
-
[field, min, max, stops],
|
|
102
|
-
);
|
|
103
|
-
}
|
package/src/useGroupReorder.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
10
|
-
|
|
11
|
-
export interface UseGroupReorderOptions {
|
|
12
|
-
/** Initial ordered list of group keys. */
|
|
13
|
-
groupKeys: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface UseGroupReorderResult {
|
|
17
|
-
/** Current ordered list of group keys. */
|
|
18
|
-
groupOrder: string[];
|
|
19
|
-
/** Move a group from one index to another. */
|
|
20
|
-
moveGroup: (fromIndex: number, toIndex: number) => void;
|
|
21
|
-
/** Drag-start handler – stores the dragged group key. */
|
|
22
|
-
onDragStart: (e: React.DragEvent, key: string) => void;
|
|
23
|
-
/** Drag-over handler – must be attached to allow drop. */
|
|
24
|
-
onDragOver: (e: React.DragEvent) => void;
|
|
25
|
-
/** Drop handler – reorders the group under the cursor. */
|
|
26
|
-
onDrop: (e: React.DragEvent, targetKey: string) => void;
|
|
27
|
-
/** Drag-end handler – cleans up drag state. */
|
|
28
|
-
onDragEnd: () => void;
|
|
29
|
-
/** The key currently being dragged (null when idle). */
|
|
30
|
-
draggingKey: string | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const DRAG_DATA_TYPE = 'text/x-group-key';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Hook for drag-and-drop reordering of grouped sections.
|
|
37
|
-
*
|
|
38
|
-
* Attach the returned handlers to the group header elements to enable
|
|
39
|
-
* reordering via native HTML drag-and-drop.
|
|
40
|
-
*/
|
|
41
|
-
export function useGroupReorder({ groupKeys }: UseGroupReorderOptions): UseGroupReorderResult {
|
|
42
|
-
const [order, setOrder] = useState<string[]>(groupKeys);
|
|
43
|
-
const [draggingKey, setDraggingKey] = useState<string | null>(null);
|
|
44
|
-
|
|
45
|
-
// Keep internal order in sync when the source list changes (new groups added/removed).
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
setOrder((prev) => {
|
|
48
|
-
const prevSet = new Set(prev);
|
|
49
|
-
const nextSet = new Set(groupKeys);
|
|
50
|
-
|
|
51
|
-
// Fast-path: identical sets in same order.
|
|
52
|
-
if (
|
|
53
|
-
prev.length === groupKeys.length &&
|
|
54
|
-
prev.every((k, i) => k === groupKeys[i])
|
|
55
|
-
) {
|
|
56
|
-
return prev;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Preserve existing order for keys that still exist, append new keys.
|
|
60
|
-
const kept = prev.filter((k) => nextSet.has(k));
|
|
61
|
-
const added = groupKeys.filter((k) => !prevSet.has(k));
|
|
62
|
-
return [...kept, ...added];
|
|
63
|
-
});
|
|
64
|
-
}, [groupKeys]);
|
|
65
|
-
|
|
66
|
-
const moveGroup = useCallback((fromIndex: number, toIndex: number) => {
|
|
67
|
-
setOrder((prev) => {
|
|
68
|
-
if (fromIndex < 0 || fromIndex >= prev.length) return prev;
|
|
69
|
-
if (toIndex < 0 || toIndex >= prev.length) return prev;
|
|
70
|
-
if (fromIndex === toIndex) return prev;
|
|
71
|
-
|
|
72
|
-
const next = [...prev];
|
|
73
|
-
const [moved] = next.splice(fromIndex, 1);
|
|
74
|
-
next.splice(toIndex, 0, moved);
|
|
75
|
-
return next;
|
|
76
|
-
});
|
|
77
|
-
}, []);
|
|
78
|
-
|
|
79
|
-
const onDragStart = useCallback((e: React.DragEvent, key: string) => {
|
|
80
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
81
|
-
e.dataTransfer.setData(DRAG_DATA_TYPE, key);
|
|
82
|
-
setDraggingKey(key);
|
|
83
|
-
}, []);
|
|
84
|
-
|
|
85
|
-
const onDragOver = useCallback((e: React.DragEvent) => {
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
e.dataTransfer.dropEffect = 'move';
|
|
88
|
-
}, []);
|
|
89
|
-
|
|
90
|
-
const onDrop = useCallback(
|
|
91
|
-
(e: React.DragEvent, targetKey: string) => {
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
const sourceKey = e.dataTransfer.getData(DRAG_DATA_TYPE);
|
|
94
|
-
if (!sourceKey || sourceKey === targetKey) return;
|
|
95
|
-
|
|
96
|
-
setOrder((prev) => {
|
|
97
|
-
const fromIndex = prev.indexOf(sourceKey);
|
|
98
|
-
const toIndex = prev.indexOf(targetKey);
|
|
99
|
-
if (fromIndex === -1 || toIndex === -1) return prev;
|
|
100
|
-
|
|
101
|
-
const next = [...prev];
|
|
102
|
-
const [moved] = next.splice(fromIndex, 1);
|
|
103
|
-
next.splice(toIndex, 0, moved);
|
|
104
|
-
return next;
|
|
105
|
-
});
|
|
106
|
-
},
|
|
107
|
-
[],
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const onDragEnd = useCallback(() => {
|
|
111
|
-
setDraggingKey(null);
|
|
112
|
-
}, []);
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
groupOrder: order,
|
|
116
|
-
moveGroup,
|
|
117
|
-
onDragStart,
|
|
118
|
-
onDragOver,
|
|
119
|
-
onDrop,
|
|
120
|
-
onDragEnd,
|
|
121
|
-
draggingKey,
|
|
122
|
-
};
|
|
123
|
-
}
|
package/src/useGroupedData.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useState, useMemo, useCallback } from 'react';
|
|
10
|
-
import type { GroupingConfig } from '@object-ui/types';
|
|
11
|
-
|
|
12
|
-
/** Supported aggregation function types. */
|
|
13
|
-
export type AggregationType = 'sum' | 'count' | 'avg' | 'min' | 'max';
|
|
14
|
-
|
|
15
|
-
/** Describes a single aggregation to compute per group. */
|
|
16
|
-
export interface AggregationConfig {
|
|
17
|
-
/** The field to aggregate. */
|
|
18
|
-
field: string;
|
|
19
|
-
/** The aggregation function. */
|
|
20
|
-
type: AggregationType;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Result of a computed aggregation for a group. */
|
|
24
|
-
export interface AggregationResult {
|
|
25
|
-
/** The field that was aggregated. */
|
|
26
|
-
field: string;
|
|
27
|
-
/** The aggregation function used. */
|
|
28
|
-
type: AggregationType;
|
|
29
|
-
/** The computed value. */
|
|
30
|
-
value: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface GroupEntry {
|
|
34
|
-
/** Composite key identifying this group (field values joined by ' / ') */
|
|
35
|
-
key: string;
|
|
36
|
-
/** Display label for the group header */
|
|
37
|
-
label: string;
|
|
38
|
-
/** Rows belonging to this group */
|
|
39
|
-
rows: any[];
|
|
40
|
-
/** Whether the group section is collapsed */
|
|
41
|
-
collapsed: boolean;
|
|
42
|
-
/** Computed aggregations for this group (empty when no aggregations configured). */
|
|
43
|
-
aggregations: AggregationResult[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface UseGroupedDataResult {
|
|
47
|
-
/** Grouped entries (empty when grouping is not configured) */
|
|
48
|
-
groups: GroupEntry[];
|
|
49
|
-
/** Whether grouping is active */
|
|
50
|
-
isGrouped: boolean;
|
|
51
|
-
/** Toggle the collapsed state of a group by its key */
|
|
52
|
-
toggleGroup: (key: string) => void;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Build a composite group key from a row based on the grouping fields.
|
|
57
|
-
*/
|
|
58
|
-
function buildGroupKey(row: Record<string, any>, fields: GroupingConfig['fields']): string {
|
|
59
|
-
return fields.map((f) => String(row[f.field] ?? '')).join(' / ');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Build a human-readable label from a row based on the grouping fields.
|
|
64
|
-
*/
|
|
65
|
-
function buildGroupLabel(row: Record<string, any>, fields: GroupingConfig['fields']): string {
|
|
66
|
-
return fields
|
|
67
|
-
.map((f) => {
|
|
68
|
-
const val = row[f.field];
|
|
69
|
-
return val !== undefined && val !== null && val !== '' ? String(val) : '(empty)';
|
|
70
|
-
})
|
|
71
|
-
.join(' / ');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Compute aggregation results for a set of rows.
|
|
76
|
-
*/
|
|
77
|
-
function computeAggregations(
|
|
78
|
-
rows: any[],
|
|
79
|
-
configs: AggregationConfig[],
|
|
80
|
-
): AggregationResult[] {
|
|
81
|
-
return configs.map(({ field, type }) => {
|
|
82
|
-
const nums = rows
|
|
83
|
-
.map((r) => Number(r[field]))
|
|
84
|
-
.filter((n) => Number.isFinite(n));
|
|
85
|
-
|
|
86
|
-
let value: number;
|
|
87
|
-
switch (type) {
|
|
88
|
-
case 'count':
|
|
89
|
-
value = nums.length;
|
|
90
|
-
break;
|
|
91
|
-
case 'sum':
|
|
92
|
-
value = nums.reduce((a, b) => a + b, 0);
|
|
93
|
-
break;
|
|
94
|
-
case 'avg':
|
|
95
|
-
value = nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
96
|
-
break;
|
|
97
|
-
case 'min':
|
|
98
|
-
value = nums.length > 0 ? Math.min(...nums) : 0;
|
|
99
|
-
break;
|
|
100
|
-
case 'max':
|
|
101
|
-
value = nums.length > 0 ? Math.max(...nums) : 0;
|
|
102
|
-
break;
|
|
103
|
-
default:
|
|
104
|
-
value = 0;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return { field, type, value };
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Compare function that respects per-field sort order.
|
|
113
|
-
*/
|
|
114
|
-
function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
|
|
115
|
-
const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
116
|
-
return order === 'desc' ? -cmp : cmp;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Hook that groups a flat data array by the fields specified in GroupingConfig.
|
|
121
|
-
*
|
|
122
|
-
* Supports multi-level grouping, per-field sort order, and per-field default
|
|
123
|
-
* collapsed state. Collapse state is managed internally so the consumer only
|
|
124
|
-
* needs to wire `toggleGroup` to the UI.
|
|
125
|
-
*
|
|
126
|
-
* @param config - GroupingConfig from the grid schema (optional)
|
|
127
|
-
* @param data - flat data rows
|
|
128
|
-
* @param aggregations - optional aggregation definitions to compute per group
|
|
129
|
-
*/
|
|
130
|
-
export function useGroupedData(
|
|
131
|
-
config: GroupingConfig | undefined,
|
|
132
|
-
data: any[],
|
|
133
|
-
aggregations?: AggregationConfig[],
|
|
134
|
-
): UseGroupedDataResult {
|
|
135
|
-
const fields = config?.fields;
|
|
136
|
-
const isGrouped = !!(fields && fields.length > 0);
|
|
137
|
-
|
|
138
|
-
// Track which group keys have been explicitly toggled by the user.
|
|
139
|
-
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});
|
|
140
|
-
|
|
141
|
-
// Determine whether a field set defaults to collapsed.
|
|
142
|
-
const fieldsDefaultCollapsed = useMemo(() => {
|
|
143
|
-
if (!fields) return false;
|
|
144
|
-
// If any grouping field has collapsed: true, default all groups to collapsed.
|
|
145
|
-
return fields.some((f) => f.collapsed);
|
|
146
|
-
}, [fields]);
|
|
147
|
-
|
|
148
|
-
const groups: GroupEntry[] = useMemo(() => {
|
|
149
|
-
if (!isGrouped || !fields) return [];
|
|
150
|
-
|
|
151
|
-
// Group rows by composite key
|
|
152
|
-
const map = new Map<string, { label: string; rows: any[] }>();
|
|
153
|
-
const keyOrder: string[] = [];
|
|
154
|
-
|
|
155
|
-
for (const row of data) {
|
|
156
|
-
const key = buildGroupKey(row, fields);
|
|
157
|
-
if (!map.has(key)) {
|
|
158
|
-
map.set(key, { label: buildGroupLabel(row, fields), rows: [] });
|
|
159
|
-
keyOrder.push(key);
|
|
160
|
-
}
|
|
161
|
-
map.get(key)!.rows.push(row);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Sort groups using the first grouping field's order
|
|
165
|
-
const primaryOrder = fields[0]?.order ?? 'asc';
|
|
166
|
-
keyOrder.sort((a, b) => compareGroups(a, b, primaryOrder));
|
|
167
|
-
|
|
168
|
-
return keyOrder.map((key) => {
|
|
169
|
-
const entry = map.get(key)!;
|
|
170
|
-
const collapsed =
|
|
171
|
-
key in toggledKeys ? toggledKeys[key] : fieldsDefaultCollapsed;
|
|
172
|
-
const agg = aggregations && aggregations.length > 0
|
|
173
|
-
? computeAggregations(entry.rows, aggregations)
|
|
174
|
-
: [];
|
|
175
|
-
return { key, label: entry.label, rows: entry.rows, collapsed, aggregations: agg };
|
|
176
|
-
});
|
|
177
|
-
}, [data, fields, isGrouped, toggledKeys, fieldsDefaultCollapsed, aggregations]);
|
|
178
|
-
|
|
179
|
-
const toggleGroup = useCallback((key: string) => {
|
|
180
|
-
setToggledKeys((prev) => ({
|
|
181
|
-
...prev,
|
|
182
|
-
[key]: prev[key] !== undefined ? !prev[key] : !fieldsDefaultCollapsed,
|
|
183
|
-
}));
|
|
184
|
-
}, [fieldsDefaultCollapsed]);
|
|
185
|
-
|
|
186
|
-
return { groups, isGrouped, toggleGroup };
|
|
187
|
-
}
|