@object-ui/plugin-grid 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +6 -6
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.js +2173 -922
  4. package/dist/index.umd.cjs +9 -3
  5. package/dist/plugin-grid/src/FormulaBar.d.ts +29 -0
  6. package/dist/plugin-grid/src/GroupRow.d.ts +23 -0
  7. package/dist/plugin-grid/src/ImportWizard.d.ts +29 -0
  8. package/dist/plugin-grid/src/ObjectGrid.d.ts +1 -0
  9. package/dist/plugin-grid/src/SplitPaneGrid.d.ts +22 -0
  10. package/dist/plugin-grid/src/components/BulkActionBar.d.ts +12 -0
  11. package/dist/plugin-grid/src/components/RowActionMenu.d.ts +23 -0
  12. package/dist/plugin-grid/src/index.d.ts +22 -2
  13. package/dist/plugin-grid/src/useCellClipboard.d.ts +47 -0
  14. package/dist/plugin-grid/src/useColumnSummary.d.ts +25 -0
  15. package/dist/plugin-grid/src/useGradientColor.d.ts +37 -0
  16. package/dist/plugin-grid/src/useGroupReorder.d.ts +34 -0
  17. package/dist/plugin-grid/src/useGroupedData.d.ts +24 -3
  18. package/package.json +10 -10
  19. package/src/FormulaBar.tsx +151 -0
  20. package/src/GroupRow.tsx +69 -0
  21. package/src/ImportWizard.tsx +412 -0
  22. package/src/ListColumnExtensions.test.tsx +4 -5
  23. package/src/ObjectGrid.tsx +1002 -139
  24. package/src/SplitPaneGrid.tsx +120 -0
  25. package/src/VirtualGrid.tsx +2 -2
  26. package/src/__tests__/GroupRow.test.tsx +206 -0
  27. package/src/__tests__/ImportPreview.test.tsx +171 -0
  28. package/src/__tests__/accessorKey-inference.test.tsx +132 -0
  29. package/src/__tests__/airtable-style.test.tsx +508 -0
  30. package/src/__tests__/column-features.test.tsx +490 -0
  31. package/src/__tests__/grid-export.test.tsx +121 -0
  32. package/src/__tests__/mobile-card-view.test.tsx +355 -0
  33. package/src/__tests__/objectdef-enrichment.test.tsx +566 -0
  34. package/src/__tests__/phase11-features.test.tsx +418 -0
  35. package/src/__tests__/row-bulk-actions.test.tsx +413 -0
  36. package/src/__tests__/row-height.test.tsx +160 -0
  37. package/src/__tests__/useGroupedData.test.ts +165 -0
  38. package/src/components/BulkActionBar.tsx +66 -0
  39. package/src/components/RowActionMenu.tsx +91 -0
  40. package/src/index.tsx +46 -2
  41. package/src/useCellClipboard.ts +136 -0
  42. package/src/useColumnSummary.ts +128 -0
  43. package/src/useGradientColor.ts +103 -0
  44. package/src/useGroupReorder.ts +123 -0
  45. package/src/useGroupedData.ts +69 -4
@@ -0,0 +1,165 @@
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 { describe, it, expect } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useGroupedData } from '../useGroupedData';
12
+
13
+ const sampleData = [
14
+ { category: 'A', priority: 'High', amount: 10 },
15
+ { category: 'A', priority: 'Low', amount: 20 },
16
+ { category: 'B', priority: 'High', amount: 30 },
17
+ { category: 'B', priority: 'Medium', amount: 40 },
18
+ { category: 'C', priority: 'Low', amount: 50 },
19
+ ];
20
+
21
+ describe('useGroupedData – collapsed state management', () => {
22
+ it('returns isGrouped=false when config is undefined', () => {
23
+ const { result } = renderHook(() => useGroupedData(undefined, sampleData));
24
+ expect(result.current.isGrouped).toBe(false);
25
+ expect(result.current.groups).toEqual([]);
26
+ });
27
+
28
+ it('returns isGrouped=false when config has empty fields', () => {
29
+ const { result } = renderHook(() => useGroupedData({ fields: [] }, sampleData));
30
+ expect(result.current.isGrouped).toBe(false);
31
+ expect(result.current.groups).toEqual([]);
32
+ });
33
+
34
+ it('groups data correctly with single field', () => {
35
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
36
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
37
+
38
+ expect(result.current.isGrouped).toBe(true);
39
+ expect(result.current.groups).toHaveLength(3);
40
+ expect(result.current.groups[0].key).toBe('A');
41
+ expect(result.current.groups[0].rows).toHaveLength(2);
42
+ expect(result.current.groups[1].key).toBe('B');
43
+ expect(result.current.groups[1].rows).toHaveLength(2);
44
+ expect(result.current.groups[2].key).toBe('C');
45
+ expect(result.current.groups[2].rows).toHaveLength(1);
46
+ });
47
+
48
+ it('all groups default to expanded when collapsed=false', () => {
49
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
50
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
51
+
52
+ result.current.groups.forEach((group) => {
53
+ expect(group.collapsed).toBe(false);
54
+ });
55
+ });
56
+
57
+ it('all groups default to collapsed when collapsed=true', () => {
58
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] };
59
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
60
+
61
+ result.current.groups.forEach((group) => {
62
+ expect(group.collapsed).toBe(true);
63
+ });
64
+ });
65
+
66
+ it('toggleGroup toggles a group from expanded to collapsed', () => {
67
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
68
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
69
+
70
+ // Initially all expanded
71
+ expect(result.current.groups[0].collapsed).toBe(false);
72
+
73
+ // Toggle group A
74
+ act(() => {
75
+ result.current.toggleGroup('A');
76
+ });
77
+
78
+ expect(result.current.groups[0].collapsed).toBe(true);
79
+ // Other groups remain expanded
80
+ expect(result.current.groups[1].collapsed).toBe(false);
81
+ expect(result.current.groups[2].collapsed).toBe(false);
82
+ });
83
+
84
+ it('toggleGroup toggles a group from collapsed back to expanded', () => {
85
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
86
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
87
+
88
+ // Toggle twice: expand -> collapse -> expand
89
+ act(() => {
90
+ result.current.toggleGroup('A');
91
+ });
92
+ expect(result.current.groups[0].collapsed).toBe(true);
93
+
94
+ act(() => {
95
+ result.current.toggleGroup('A');
96
+ });
97
+ expect(result.current.groups[0].collapsed).toBe(false);
98
+ });
99
+
100
+ it('toggleGroup expands a group that defaults to collapsed', () => {
101
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] };
102
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
103
+
104
+ // Initially all collapsed
105
+ expect(result.current.groups[0].collapsed).toBe(true);
106
+
107
+ // Toggle group A to expand
108
+ act(() => {
109
+ result.current.toggleGroup('A');
110
+ });
111
+
112
+ expect(result.current.groups[0].collapsed).toBe(false);
113
+ // Other groups remain collapsed
114
+ expect(result.current.groups[1].collapsed).toBe(true);
115
+ });
116
+
117
+ it('sorts groups in descending order when configured', () => {
118
+ const config = { fields: [{ field: 'category', order: 'desc' as const, collapsed: false }] };
119
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
120
+
121
+ expect(result.current.groups[0].key).toBe('C');
122
+ expect(result.current.groups[1].key).toBe('B');
123
+ expect(result.current.groups[2].key).toBe('A');
124
+ });
125
+
126
+ it('builds correct labels for groups', () => {
127
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
128
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
129
+
130
+ expect(result.current.groups[0].label).toBe('A');
131
+ expect(result.current.groups[1].label).toBe('B');
132
+ expect(result.current.groups[2].label).toBe('C');
133
+ });
134
+
135
+ it('shows (empty) label for rows with missing grouping field', () => {
136
+ const data = [
137
+ { category: 'A', amount: 10 },
138
+ { amount: 20 }, // no category
139
+ { category: '', amount: 30 }, // empty category
140
+ ];
141
+ const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
142
+ const { result } = renderHook(() => useGroupedData(config, data));
143
+
144
+ const emptyGroup = result.current.groups.find((g) => g.label === '(empty)');
145
+ expect(emptyGroup).toBeDefined();
146
+ expect(emptyGroup!.rows).toHaveLength(2);
147
+ });
148
+
149
+ it('supports multi-field grouping', () => {
150
+ const config = {
151
+ fields: [
152
+ { field: 'category', order: 'asc' as const, collapsed: false },
153
+ { field: 'priority', order: 'asc' as const, collapsed: false },
154
+ ],
155
+ };
156
+ const { result } = renderHook(() => useGroupedData(config, sampleData));
157
+
158
+ expect(result.current.isGrouped).toBe(true);
159
+ // Each unique combination of category + priority should be a group
160
+ expect(result.current.groups.length).toBeGreaterThanOrEqual(4);
161
+ // Check label format is "A / High"
162
+ const firstGroup = result.current.groups[0];
163
+ expect(firstGroup.label).toContain(' / ');
164
+ });
165
+ });
@@ -0,0 +1,66 @@
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 React from 'react';
10
+ import { Button } from '@object-ui/components';
11
+ import { formatActionLabel } from './RowActionMenu';
12
+
13
+ export interface BulkActionBarProps {
14
+ /** Array of selected row records */
15
+ selectedRows: any[];
16
+ /** Bulk/batch action identifiers */
17
+ actions: string[];
18
+ /** Callback when a bulk action button is clicked */
19
+ onAction?: (action: string, selectedRows: any[]) => void;
20
+ /** Callback to clear selection */
21
+ onClearSelection?: () => void;
22
+ }
23
+
24
+ export const BulkActionBar: React.FC<BulkActionBarProps> = ({
25
+ selectedRows,
26
+ actions,
27
+ onAction,
28
+ onClearSelection,
29
+ }) => {
30
+ if (!actions || actions.length === 0 || selectedRows.length === 0) {
31
+ return null;
32
+ }
33
+
34
+ return (
35
+ <div
36
+ className="border-t px-4 py-1.5 flex items-center gap-2 text-xs bg-primary/5 shrink-0"
37
+ data-testid="bulk-actions-bar"
38
+ >
39
+ <span className="text-muted-foreground font-medium">
40
+ {selectedRows.length} selected
41
+ </span>
42
+ <div className="flex items-center gap-1 ml-2">
43
+ {actions.map(action => (
44
+ <Button
45
+ key={action}
46
+ variant="outline"
47
+ size="sm"
48
+ className="h-6 px-2 text-xs"
49
+ onClick={() => onAction?.(action, selectedRows)}
50
+ data-testid={`bulk-action-${action}`}
51
+ >
52
+ {formatActionLabel(action)}
53
+ </Button>
54
+ ))}
55
+ </div>
56
+ <Button
57
+ variant="ghost"
58
+ size="sm"
59
+ className="h-6 px-2 text-xs ml-auto"
60
+ onClick={onClearSelection}
61
+ >
62
+ Clear
63
+ </Button>
64
+ </div>
65
+ );
66
+ };
@@ -0,0 +1,91 @@
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 React from 'react';
10
+ import {
11
+ Button,
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuItem,
15
+ DropdownMenuTrigger,
16
+ } from '@object-ui/components';
17
+ import { Edit, Trash2, MoreVertical } from 'lucide-react';
18
+
19
+ /**
20
+ * Format an action identifier string into a human-readable label.
21
+ * e.g., 'send_email' → 'Send Email'
22
+ */
23
+ export function formatActionLabel(action: string): string {
24
+ return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
25
+ }
26
+
27
+ export interface RowActionMenuProps {
28
+ /** The row data record */
29
+ row: any;
30
+ /** Custom row action identifiers */
31
+ rowActions?: string[];
32
+ /** Whether edit operation is available */
33
+ canEdit?: boolean;
34
+ /** Whether delete operation is available */
35
+ canDelete?: boolean;
36
+ /** Callback when edit is clicked */
37
+ onEdit?: (row: any) => void;
38
+ /** Callback when delete is clicked */
39
+ onDelete?: (row: any) => void;
40
+ /** Callback when a custom row action is clicked */
41
+ onAction?: (action: string, row: any) => void;
42
+ }
43
+
44
+ export const RowActionMenu: React.FC<RowActionMenuProps> = ({
45
+ row,
46
+ rowActions,
47
+ canEdit,
48
+ canDelete,
49
+ onEdit,
50
+ onDelete,
51
+ onAction,
52
+ }) => {
53
+ return (
54
+ <DropdownMenu>
55
+ <DropdownMenuTrigger asChild>
56
+ <Button
57
+ variant="ghost"
58
+ size="icon"
59
+ className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0"
60
+ data-testid="row-action-trigger"
61
+ >
62
+ <MoreVertical className="h-4 w-4" />
63
+ <span className="sr-only">Open menu</span>
64
+ </Button>
65
+ </DropdownMenuTrigger>
66
+ <DropdownMenuContent align="end">
67
+ {canEdit && onEdit && (
68
+ <DropdownMenuItem onClick={() => onEdit(row)}>
69
+ <Edit className="mr-2 h-4 w-4" />
70
+ Edit
71
+ </DropdownMenuItem>
72
+ )}
73
+ {canDelete && onDelete && (
74
+ <DropdownMenuItem onClick={() => onDelete(row)}>
75
+ <Trash2 className="mr-2 h-4 w-4" />
76
+ Delete
77
+ </DropdownMenuItem>
78
+ )}
79
+ {rowActions?.map(action => (
80
+ <DropdownMenuItem
81
+ key={action}
82
+ onClick={() => onAction?.(action, row)}
83
+ data-testid={`row-action-${action}`}
84
+ >
85
+ {formatActionLabel(action)}
86
+ </DropdownMenuItem>
87
+ ))}
88
+ </DropdownMenuContent>
89
+ </DropdownMenu>
90
+ );
91
+ };
package/src/index.tsx CHANGED
@@ -11,15 +11,35 @@ import { ComponentRegistry } from '@object-ui/core';
11
11
  import { useSchemaContext } from '@object-ui/react';
12
12
  import { ObjectGrid } from './ObjectGrid';
13
13
  import { VirtualGrid } from './VirtualGrid';
14
+ import { ImportWizard } from './ImportWizard';
14
15
 
15
- export { ObjectGrid, VirtualGrid };
16
+ export { ObjectGrid, VirtualGrid, ImportWizard };
16
17
  export { InlineEditing } from './InlineEditing';
17
18
  export { useRowColor } from './useRowColor';
18
19
  export { useGroupedData } from './useGroupedData';
20
+ export { GroupRow } from './GroupRow';
21
+ export { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
22
+ export { BulkActionBar } from './components/BulkActionBar';
23
+ export { useCellClipboard } from './useCellClipboard';
24
+ export { useGradientColor } from './useGradientColor';
25
+ export { useGroupReorder } from './useGroupReorder';
26
+ export { useColumnSummary } from './useColumnSummary';
27
+ export { FormulaBar } from './FormulaBar';
28
+ export { SplitPaneGrid } from './SplitPaneGrid';
19
29
  export type { ObjectGridProps } from './ObjectGrid';
20
30
  export type { VirtualGridProps, VirtualGridColumn } from './VirtualGrid';
21
31
  export type { InlineEditingProps } from './InlineEditing';
22
- export type { GroupEntry, UseGroupedDataResult } from './useGroupedData';
32
+ export type { ImportWizardProps, ImportResult } from './ImportWizard';
33
+ export type { GroupEntry, UseGroupedDataResult, AggregationType, AggregationConfig, AggregationResult } from './useGroupedData';
34
+ export type { GroupRowProps } from './GroupRow';
35
+ export type { RowActionMenuProps } from './components/RowActionMenu';
36
+ export type { BulkActionBarProps } from './components/BulkActionBar';
37
+ export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard';
38
+ export type { GradientStop, UseGradientColorOptions } from './useGradientColor';
39
+ export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder';
40
+ export type { ColumnSummaryConfig, ColumnSummaryResult } from './useColumnSummary';
41
+ export type { FormulaBarProps } from './FormulaBar';
42
+ export type { SplitPaneGridProps } from './SplitPaneGrid';
23
43
 
24
44
  // Register object-grid component
25
45
  export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
@@ -51,5 +71,29 @@ ComponentRegistry.register('grid', ObjectGridRenderer, {
51
71
  ]
52
72
  });
53
73
 
74
+ // Register import-wizard component
75
+ const ImportWizardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
76
+ const { dataSource } = useSchemaContext() || {};
77
+ return (
78
+ <ImportWizard
79
+ objectName={schema.objectName}
80
+ objectLabel={schema.objectLabel}
81
+ fields={schema.fields ?? []}
82
+ dataSource={dataSource}
83
+ {...props}
84
+ />
85
+ );
86
+ };
87
+
88
+ ComponentRegistry.register('import-wizard', ImportWizardRenderer, {
89
+ namespace: 'plugin-grid',
90
+ label: 'Import Wizard',
91
+ category: 'plugin',
92
+ inputs: [
93
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
94
+ { name: 'fields', type: 'array', label: 'Fields', required: true },
95
+ ]
96
+ });
97
+
54
98
  // Note: 'grid' type is handled by @object-ui/components Grid layout component
55
99
  // This plugin only handles 'object-grid' which integrates with ObjectQL/ObjectStack
@@ -0,0 +1,136 @@
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
+ }
@@ -0,0 +1,128 @@
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
+ }