@object-ui/plugin-grid 3.0.3 → 3.1.0
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/.turbo/turbo-build.log +12 -6
- package/dist/index.js +2169 -922
- package/dist/index.umd.cjs +9 -3
- package/dist/plugin-grid/src/FormulaBar.d.ts +29 -0
- package/dist/plugin-grid/src/GroupRow.d.ts +23 -0
- package/dist/plugin-grid/src/ImportWizard.d.ts +29 -0
- package/dist/plugin-grid/src/ObjectGrid.d.ts +1 -0
- package/dist/plugin-grid/src/SplitPaneGrid.d.ts +22 -0
- package/dist/plugin-grid/src/components/BulkActionBar.d.ts +12 -0
- package/dist/plugin-grid/src/components/RowActionMenu.d.ts +23 -0
- package/dist/plugin-grid/src/index.d.ts +22 -2
- package/dist/plugin-grid/src/useCellClipboard.d.ts +47 -0
- package/dist/plugin-grid/src/useColumnSummary.d.ts +25 -0
- package/dist/plugin-grid/src/useGradientColor.d.ts +37 -0
- package/dist/plugin-grid/src/useGroupReorder.d.ts +34 -0
- package/dist/plugin-grid/src/useGroupedData.d.ts +24 -3
- package/package.json +10 -10
- package/src/FormulaBar.tsx +151 -0
- package/src/GroupRow.tsx +69 -0
- package/src/ImportWizard.tsx +412 -0
- package/src/ListColumnExtensions.test.tsx +4 -5
- package/src/ObjectGrid.tsx +994 -139
- package/src/SplitPaneGrid.tsx +120 -0
- package/src/VirtualGrid.tsx +2 -2
- package/src/__tests__/GroupRow.test.tsx +206 -0
- package/src/__tests__/ImportPreview.test.tsx +171 -0
- package/src/__tests__/accessorKey-inference.test.tsx +132 -0
- package/src/__tests__/airtable-style.test.tsx +508 -0
- package/src/__tests__/column-features.test.tsx +490 -0
- package/src/__tests__/grid-export.test.tsx +121 -0
- package/src/__tests__/mobile-card-view.test.tsx +355 -0
- package/src/__tests__/objectdef-enrichment.test.tsx +566 -0
- package/src/__tests__/phase11-features.test.tsx +418 -0
- package/src/__tests__/row-bulk-actions.test.tsx +413 -0
- package/src/__tests__/row-height.test.tsx +160 -0
- package/src/__tests__/useGroupedData.test.ts +165 -0
- package/src/components/BulkActionBar.tsx +66 -0
- package/src/components/RowActionMenu.tsx +91 -0
- package/src/index.tsx +46 -2
- package/src/useCellClipboard.ts +136 -0
- package/src/useColumnSummary.ts +128 -0
- package/src/useGradientColor.ts +103 -0
- package/src/useGroupReorder.ts +123 -0
- 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 {
|
|
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
|
+
}
|