@object-ui/plugin-grid 2.0.0 → 3.0.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.
- package/.turbo/turbo-build.log +42 -6
- package/CHANGELOG.md +34 -0
- package/dist/index.js +960 -641
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
- package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +5 -0
- package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
- package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
- package/package.json +11 -10
- package/src/InlineEditing.tsx +235 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +148 -16
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/accessibility.test.tsx +254 -0
- package/src/__tests__/performance-benchmark.test.tsx +182 -0
- package/src/__tests__/view-states.test.tsx +203 -0
- package/src/index.tsx +5 -0
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
import { createStorybookDataSource } from '@storybook-config/datasource';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Plugins/ObjectGrid/Edge Cases',
|
|
8
|
+
component: SchemaRenderer,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
argTypes: {
|
|
14
|
+
schema: { table: { disable: true } },
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<any>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
const dataSource = createStorybookDataSource();
|
|
22
|
+
|
|
23
|
+
const renderStory = (args: any) => (
|
|
24
|
+
<SchemaRendererProvider dataSource={dataSource}>
|
|
25
|
+
<SchemaRenderer schema={args as unknown as BaseSchema} />
|
|
26
|
+
</SchemaRendererProvider>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// ── Empty Data ────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const EmptyData: Story = {
|
|
32
|
+
name: 'Empty – No Rows',
|
|
33
|
+
render: renderStory,
|
|
34
|
+
args: {
|
|
35
|
+
type: 'object-grid',
|
|
36
|
+
objectName: 'Employee',
|
|
37
|
+
columns: [
|
|
38
|
+
{ field: 'id', header: 'ID', width: 80 },
|
|
39
|
+
{ field: 'name', header: 'Name' },
|
|
40
|
+
{ field: 'email', header: 'Email' },
|
|
41
|
+
{ field: 'department', header: 'Department' },
|
|
42
|
+
],
|
|
43
|
+
data: [],
|
|
44
|
+
pagination: false,
|
|
45
|
+
className: 'w-full',
|
|
46
|
+
} as any,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Single Row ────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const SingleRow: Story = {
|
|
52
|
+
name: 'Single Row',
|
|
53
|
+
render: renderStory,
|
|
54
|
+
args: {
|
|
55
|
+
type: 'object-grid',
|
|
56
|
+
objectName: 'Employee',
|
|
57
|
+
columns: [
|
|
58
|
+
{ field: 'id', header: 'ID', width: 80 },
|
|
59
|
+
{ field: 'name', header: 'Name', sortable: true },
|
|
60
|
+
{ field: 'email', header: 'Email' },
|
|
61
|
+
{ field: 'department', header: 'Department' },
|
|
62
|
+
],
|
|
63
|
+
data: [
|
|
64
|
+
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
|
|
65
|
+
],
|
|
66
|
+
pagination: false,
|
|
67
|
+
className: 'w-full',
|
|
68
|
+
} as any,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── Many Columns ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export const ManyColumns: Story = {
|
|
74
|
+
name: 'Many Columns (15+)',
|
|
75
|
+
render: renderStory,
|
|
76
|
+
args: {
|
|
77
|
+
type: 'object-grid',
|
|
78
|
+
objectName: 'WideTable',
|
|
79
|
+
columns: Array.from({ length: 18 }, (_, i) => ({
|
|
80
|
+
field: `col${i + 1}`,
|
|
81
|
+
header: `Column ${i + 1}`,
|
|
82
|
+
sortable: i < 5,
|
|
83
|
+
})),
|
|
84
|
+
data: Array.from({ length: 5 }, (_, row) => {
|
|
85
|
+
const record: Record<string, any> = {};
|
|
86
|
+
for (let c = 1; c <= 18; c++) {
|
|
87
|
+
record[`col${c}`] = `R${row + 1}-C${c}`;
|
|
88
|
+
}
|
|
89
|
+
return record;
|
|
90
|
+
}),
|
|
91
|
+
pagination: false,
|
|
92
|
+
className: 'w-full',
|
|
93
|
+
} as any,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── Very Long Cell Values ─────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const LONG_VALUE =
|
|
99
|
+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
|
100
|
+
|
|
101
|
+
export const LongCellValues: Story = {
|
|
102
|
+
name: 'Very Long Cell Values',
|
|
103
|
+
render: renderStory,
|
|
104
|
+
args: {
|
|
105
|
+
type: 'object-grid',
|
|
106
|
+
objectName: 'Article',
|
|
107
|
+
columns: [
|
|
108
|
+
{ field: 'id', header: 'ID', width: 60 },
|
|
109
|
+
{ field: 'title', header: 'Title' },
|
|
110
|
+
{ field: 'abstract', header: 'Abstract' },
|
|
111
|
+
{ field: 'author', header: 'Author' },
|
|
112
|
+
],
|
|
113
|
+
data: [
|
|
114
|
+
{ id: 1, title: LONG_VALUE, abstract: LONG_VALUE + ' ' + LONG_VALUE, author: 'Dr. Extremely Long Author Name The Third Junior' },
|
|
115
|
+
{ id: 2, title: 'Short', abstract: 'Brief.', author: 'Bob' },
|
|
116
|
+
{ id: 3, title: LONG_VALUE, abstract: LONG_VALUE, author: LONG_VALUE },
|
|
117
|
+
],
|
|
118
|
+
pagination: false,
|
|
119
|
+
className: 'w-full',
|
|
120
|
+
} as any,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ── Null / Undefined Values ───────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export const NullAndUndefinedValues: Story = {
|
|
126
|
+
name: 'Null / Undefined Cell Values',
|
|
127
|
+
render: renderStory,
|
|
128
|
+
args: {
|
|
129
|
+
type: 'object-grid',
|
|
130
|
+
objectName: 'Sparse',
|
|
131
|
+
columns: [
|
|
132
|
+
{ field: 'id', header: 'ID', width: 60 },
|
|
133
|
+
{ field: 'name', header: 'Name' },
|
|
134
|
+
{ field: 'email', header: 'Email' },
|
|
135
|
+
{ field: 'phone', header: 'Phone' },
|
|
136
|
+
{ field: 'notes', header: 'Notes' },
|
|
137
|
+
],
|
|
138
|
+
data: [
|
|
139
|
+
{ id: 1, name: 'Alice', email: null, phone: undefined, notes: '' },
|
|
140
|
+
{ id: 2, name: null, email: 'bob@example.com', phone: null, notes: undefined },
|
|
141
|
+
{ id: 3, name: undefined, email: undefined, phone: undefined, notes: null },
|
|
142
|
+
{ id: 4, name: 'Dave', email: 'dave@example.com', phone: '+1-555-0100', notes: 'Complete record' },
|
|
143
|
+
],
|
|
144
|
+
pagination: false,
|
|
145
|
+
className: 'w-full',
|
|
146
|
+
} as any,
|
|
147
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
import { createStorybookDataSource } from '@storybook-config/datasource';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Plugins/ObjectGrid',
|
|
8
|
+
component: SchemaRenderer,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
argTypes: {
|
|
14
|
+
schema: { table: { disable: true } },
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<any>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
const dataSource = createStorybookDataSource();
|
|
22
|
+
|
|
23
|
+
const renderStory = (args: any) => (
|
|
24
|
+
<SchemaRendererProvider dataSource={dataSource}>
|
|
25
|
+
<SchemaRenderer schema={args as unknown as BaseSchema} />
|
|
26
|
+
</SchemaRendererProvider>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: renderStory,
|
|
31
|
+
args: {
|
|
32
|
+
type: 'object-grid',
|
|
33
|
+
objectName: 'Employee',
|
|
34
|
+
columns: [
|
|
35
|
+
{ field: 'id', header: 'ID', width: 80 },
|
|
36
|
+
{ field: 'name', header: 'Name', sortable: true, filterable: true },
|
|
37
|
+
{ field: 'email', header: 'Email', sortable: true, filterable: true },
|
|
38
|
+
{ field: 'department', header: 'Department', sortable: true },
|
|
39
|
+
{ field: 'status', header: 'Status', sortable: true },
|
|
40
|
+
],
|
|
41
|
+
data: [
|
|
42
|
+
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering', status: 'Active' },
|
|
43
|
+
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing', status: 'Active' },
|
|
44
|
+
{ id: 3, name: 'Carol White', email: 'carol@example.com', department: 'Sales', status: 'Inactive' },
|
|
45
|
+
{ id: 4, name: 'Dave Brown', email: 'dave@example.com', department: 'Engineering', status: 'Active' },
|
|
46
|
+
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', department: 'HR', status: 'Active' },
|
|
47
|
+
],
|
|
48
|
+
pagination: true,
|
|
49
|
+
pageSize: 10,
|
|
50
|
+
className: 'w-full',
|
|
51
|
+
} as any,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const WithRowActions: Story = {
|
|
55
|
+
render: renderStory,
|
|
56
|
+
args: {
|
|
57
|
+
type: 'object-grid',
|
|
58
|
+
objectName: 'Task',
|
|
59
|
+
columns: [
|
|
60
|
+
{ field: 'id', header: 'ID', width: 80 },
|
|
61
|
+
{ field: 'title', header: 'Title', sortable: true, filterable: true },
|
|
62
|
+
{ field: 'assignee', header: 'Assignee', sortable: true },
|
|
63
|
+
{ field: 'priority', header: 'Priority', sortable: true },
|
|
64
|
+
{ field: 'status', header: 'Status', sortable: true },
|
|
65
|
+
],
|
|
66
|
+
actions: [
|
|
67
|
+
{ label: 'View', action: 'view' },
|
|
68
|
+
{ label: 'Edit', action: 'edit' },
|
|
69
|
+
{ label: 'Delete', action: 'delete', variant: 'destructive' },
|
|
70
|
+
],
|
|
71
|
+
data: [
|
|
72
|
+
{ id: 1, title: 'Fix login bug', assignee: 'Alice', priority: 'High', status: 'In Progress' },
|
|
73
|
+
{ id: 2, title: 'Add dark mode', assignee: 'Bob', priority: 'Medium', status: 'To Do' },
|
|
74
|
+
{ id: 3, title: 'Update docs', assignee: 'Carol', priority: 'Low', status: 'Done' },
|
|
75
|
+
],
|
|
76
|
+
pagination: true,
|
|
77
|
+
pageSize: 10,
|
|
78
|
+
className: 'w-full',
|
|
79
|
+
} as any,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* CRM Deals Pipeline — demonstrates professional data formatting:
|
|
84
|
+
* - Currency with thousand separators (Amount column, right-aligned)
|
|
85
|
+
* - Percentage with progress bar (Probability column, right-aligned)
|
|
86
|
+
* - Formatted dates (Close Date column)
|
|
87
|
+
* - Colored badges for stage/status (Stage column)
|
|
88
|
+
* - Bold clickable name as primary link (Name column)
|
|
89
|
+
* - Empty value placeholder (Account column)
|
|
90
|
+
*/
|
|
91
|
+
export const CRMDeals: Story = {
|
|
92
|
+
name: 'CRM Deals Pipeline',
|
|
93
|
+
render: renderStory,
|
|
94
|
+
args: {
|
|
95
|
+
type: 'object-grid',
|
|
96
|
+
objectName: 'Deal',
|
|
97
|
+
columns: [
|
|
98
|
+
{ field: 'name', label: 'Name', link: true, sortable: true },
|
|
99
|
+
{ field: 'account', label: 'Account' },
|
|
100
|
+
{ field: 'stage', label: 'Stage', type: 'select', sortable: true },
|
|
101
|
+
{ field: 'amount', label: 'Amount', type: 'currency', sortable: true },
|
|
102
|
+
{ field: 'probability', label: 'Probability', type: 'percent', sortable: true },
|
|
103
|
+
{ field: 'close_date', label: 'Close Date', type: 'date', sortable: true },
|
|
104
|
+
],
|
|
105
|
+
data: [
|
|
106
|
+
{ _id: '1', name: 'ObjectStack Enterprise License', account: '', stage: 'Closed Won', amount: 150000, probability: 100, close_date: '2024-01-15T00:00:00.000Z' },
|
|
107
|
+
{ _id: '2', name: 'Cloud Migration Project', account: 'Acme Corp', stage: 'Negotiation', amount: 85000, probability: 60, close_date: '2024-03-20T00:00:00.000Z' },
|
|
108
|
+
{ _id: '3', name: 'Annual Support Renewal', account: '', stage: 'Proposal', amount: 42000, probability: 80, close_date: '2024-02-28T00:00:00.000Z' },
|
|
109
|
+
{ _id: '4', name: 'Custom Integration Development', account: 'TechFlow Inc', stage: 'Qualification', amount: 230000, probability: 30, close_date: '2024-06-15T00:00:00.000Z' },
|
|
110
|
+
{ _id: '5', name: 'Data Analytics Platform', account: '', stage: 'Closed Lost', amount: 95000, probability: 0, close_date: '2024-01-10T00:00:00.000Z' },
|
|
111
|
+
{ _id: '6', name: 'Security Audit Contract', account: 'SecureNet', stage: 'Closed Won', amount: 67500, probability: 100, close_date: '2024-02-01T00:00:00.000Z' },
|
|
112
|
+
{ _id: '7', name: 'Mobile App Development', account: '', stage: 'Discovery', amount: 180000, probability: 15, close_date: '2024-08-30T00:00:00.000Z' },
|
|
113
|
+
],
|
|
114
|
+
pagination: false,
|
|
115
|
+
className: 'w-full',
|
|
116
|
+
} as any,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const EditableGrid: Story = {
|
|
120
|
+
render: renderStory,
|
|
121
|
+
args: {
|
|
122
|
+
type: 'object-grid',
|
|
123
|
+
objectName: 'Product',
|
|
124
|
+
columns: [
|
|
125
|
+
{ field: 'sku', header: 'SKU', width: 100, editable: false },
|
|
126
|
+
{ field: 'name', header: 'Name', sortable: true },
|
|
127
|
+
{ field: 'price', header: 'Price', sortable: true },
|
|
128
|
+
{ field: 'stock', header: 'Stock', sortable: true },
|
|
129
|
+
],
|
|
130
|
+
data: [
|
|
131
|
+
{ sku: 'SKU-001', name: 'Widget A', price: '$19.99', stock: 50 },
|
|
132
|
+
{ sku: 'SKU-002', name: 'Widget B', price: '$29.99', stock: 30 },
|
|
133
|
+
{ sku: 'SKU-003', name: 'Widget C', price: '$9.99', stock: 120 },
|
|
134
|
+
],
|
|
135
|
+
editable: true,
|
|
136
|
+
pagination: false,
|
|
137
|
+
className: 'w-full',
|
|
138
|
+
} as any,
|
|
139
|
+
};
|
package/src/ObjectGrid.tsx
CHANGED
|
@@ -26,13 +26,16 @@ import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object
|
|
|
26
26
|
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
|
|
27
27
|
import { getCellRenderer } from '@object-ui/fields';
|
|
28
28
|
import { Button, NavigationOverlay } from '@object-ui/components';
|
|
29
|
+
import { usePullToRefresh } from '@object-ui/mobile';
|
|
29
30
|
import {
|
|
30
31
|
DropdownMenu,
|
|
31
32
|
DropdownMenuContent,
|
|
32
33
|
DropdownMenuItem,
|
|
33
34
|
DropdownMenuTrigger,
|
|
34
35
|
} from '@object-ui/components';
|
|
35
|
-
import { Edit, Trash2, MoreVertical } from 'lucide-react';
|
|
36
|
+
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
|
37
|
+
import { useRowColor } from './useRowColor';
|
|
38
|
+
import { useGroupedData } from './useGroupedData';
|
|
36
39
|
|
|
37
40
|
export interface ObjectGridProps {
|
|
38
41
|
schema: ObjectGridSchema;
|
|
@@ -120,6 +123,24 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
120
123
|
const [loading, setLoading] = useState(true);
|
|
121
124
|
const [error, setError] = useState<Error | null>(null);
|
|
122
125
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
126
|
+
const [useCardView, setUseCardView] = useState(false);
|
|
127
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
128
|
+
|
|
129
|
+
const handlePullRefresh = useCallback(async () => {
|
|
130
|
+
setRefreshKey(k => k + 1);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
|
|
134
|
+
onRefresh: handlePullRefresh,
|
|
135
|
+
enabled: !!dataSource && !!schema.objectName,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const checkWidth = () => setUseCardView(window.innerWidth < 480);
|
|
140
|
+
checkWidth();
|
|
141
|
+
window.addEventListener('resize', checkWidth);
|
|
142
|
+
return () => window.removeEventListener('resize', checkWidth);
|
|
143
|
+
}, []);
|
|
123
144
|
|
|
124
145
|
// Check if data is passed directly (from ListView)
|
|
125
146
|
const passedData = (rest as any).data;
|
|
@@ -272,7 +293,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
272
293
|
return () => {
|
|
273
294
|
cancelled = true;
|
|
274
295
|
};
|
|
275
|
-
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig]);
|
|
296
|
+
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);
|
|
276
297
|
|
|
277
298
|
// --- NavigationConfig support ---
|
|
278
299
|
// Must be called before any early returns to satisfy React hooks rules
|
|
@@ -286,6 +307,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
286
307
|
// --- Action support for action columns ---
|
|
287
308
|
const { execute: executeAction } = useAction();
|
|
288
309
|
|
|
310
|
+
// --- Row color support ---
|
|
311
|
+
const getRowClassName = useRowColor(schema.rowColor);
|
|
312
|
+
|
|
313
|
+
// --- Grouping support ---
|
|
314
|
+
const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
|
|
315
|
+
|
|
289
316
|
const generateColumns = useCallback(() => {
|
|
290
317
|
// Use normalized columns (support both new and legacy)
|
|
291
318
|
const cols = normalizeColumns(schemaColumns);
|
|
@@ -305,7 +332,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
305
332
|
if ('field' in firstCol) {
|
|
306
333
|
return (cols as ListColumn[])
|
|
307
334
|
.filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
|
|
308
|
-
.map((col) => {
|
|
335
|
+
.map((col, colIndex) => {
|
|
309
336
|
const header = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
|
|
310
337
|
|
|
311
338
|
// Build custom cell renderer based on column configuration
|
|
@@ -319,11 +346,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
319
346
|
cellRenderer = (value: any, row: any) => {
|
|
320
347
|
const displayContent = CellRenderer
|
|
321
348
|
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
322
|
-
:
|
|
349
|
+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
|
|
323
350
|
return (
|
|
324
351
|
<button
|
|
325
352
|
type="button"
|
|
326
|
-
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
353
|
+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
327
354
|
onClick={(e) => {
|
|
328
355
|
e.stopPropagation();
|
|
329
356
|
navigation.handleClick(row);
|
|
@@ -338,11 +365,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
338
365
|
cellRenderer = (value: any, row: any) => {
|
|
339
366
|
const displayContent = CellRenderer
|
|
340
367
|
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
341
|
-
:
|
|
368
|
+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
|
|
342
369
|
return (
|
|
343
370
|
<button
|
|
344
371
|
type="button"
|
|
345
|
-
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
372
|
+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
346
373
|
onClick={(e) => {
|
|
347
374
|
e.stopPropagation();
|
|
348
375
|
navigation.handleClick(row);
|
|
@@ -357,7 +384,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
357
384
|
cellRenderer = (value: any, row: any) => {
|
|
358
385
|
const displayContent = CellRenderer
|
|
359
386
|
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
360
|
-
:
|
|
387
|
+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
|
|
361
388
|
return (
|
|
362
389
|
<button
|
|
363
390
|
type="button"
|
|
@@ -379,13 +406,28 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
379
406
|
cellRenderer = (value: any) => (
|
|
380
407
|
<CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
381
408
|
);
|
|
409
|
+
} else {
|
|
410
|
+
// Default renderer with empty value handling
|
|
411
|
+
cellRenderer = (value: any) => (
|
|
412
|
+
value != null && value !== ''
|
|
413
|
+
? <span>{String(value)}</span>
|
|
414
|
+
: <span className="text-muted-foreground">-</span>
|
|
415
|
+
);
|
|
382
416
|
}
|
|
383
417
|
|
|
418
|
+
// Auto-infer alignment from field type if not explicitly set
|
|
419
|
+
const numericTypes = ['number', 'currency', 'percent'];
|
|
420
|
+
const inferredAlign = col.align || (col.type && numericTypes.includes(col.type) ? 'right' as const : undefined);
|
|
421
|
+
|
|
422
|
+
// Determine if column should be hidden on mobile
|
|
423
|
+
const isEssential = colIndex === 0 || (col as any).essential === true;
|
|
424
|
+
|
|
384
425
|
return {
|
|
385
426
|
header,
|
|
386
427
|
accessorKey: col.field,
|
|
428
|
+
...(!isEssential && { className: 'hidden sm:table-cell' }),
|
|
387
429
|
...(col.width && { width: col.width }),
|
|
388
|
-
...(
|
|
430
|
+
...(inferredAlign && { align: inferredAlign }),
|
|
389
431
|
sortable: col.sortable !== false,
|
|
390
432
|
...(col.resizable !== undefined && { resizable: col.resizable }),
|
|
391
433
|
...(col.wrap !== undefined && { wrap: col.wrap }),
|
|
@@ -431,9 +473,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
431
473
|
if (field.permissions && field.permissions.read === false) return;
|
|
432
474
|
|
|
433
475
|
const CellRenderer = getCellRenderer(field.type);
|
|
476
|
+
const numericTypes = ['number', 'currency', 'percent'];
|
|
434
477
|
generatedColumns.push({
|
|
435
478
|
header: field.label || fieldName,
|
|
436
479
|
accessorKey: fieldName,
|
|
480
|
+
...(numericTypes.includes(field.type) && { align: 'right' }),
|
|
437
481
|
cell: (value: any) => <CellRenderer value={value} field={field} />,
|
|
438
482
|
sortable: field.sortable !== false,
|
|
439
483
|
});
|
|
@@ -444,7 +488,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
444
488
|
|
|
445
489
|
if (error) {
|
|
446
490
|
return (
|
|
447
|
-
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
491
|
+
<div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
|
|
448
492
|
<h3 className="text-red-800 font-semibold">Error loading grid</h3>
|
|
449
493
|
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
450
494
|
</div>
|
|
@@ -453,7 +497,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
453
497
|
|
|
454
498
|
if (loading && data.length === 0) {
|
|
455
499
|
return (
|
|
456
|
-
<div className="p-8 text-center">
|
|
500
|
+
<div className="p-4 sm:p-8 text-center">
|
|
457
501
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
458
502
|
<p className="mt-2 text-sm text-gray-600">Loading grid...</p>
|
|
459
503
|
</div>
|
|
@@ -472,7 +516,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
472
516
|
cell: (_value: any, row: any) => (
|
|
473
517
|
<DropdownMenu>
|
|
474
518
|
<DropdownMenuTrigger asChild>
|
|
475
|
-
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
519
|
+
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0">
|
|
476
520
|
<MoreVertical className="h-4 w-4" />
|
|
477
521
|
<span className="sr-only">Open menu</span>
|
|
478
522
|
</Button>
|
|
@@ -536,6 +580,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
536
580
|
reorderableColumns: schema.reorderableColumns ?? false,
|
|
537
581
|
editable: schema.editable ?? false,
|
|
538
582
|
className: schema.className,
|
|
583
|
+
cellClassName: 'px-2 py-1.5 sm:px-3 sm:py-2 md:px-4 md:py-2.5',
|
|
584
|
+
rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
|
|
539
585
|
onSelectionChange: onRowSelect,
|
|
540
586
|
onRowClick: navigation.handleClick,
|
|
541
587
|
onCellChange: onCellChange,
|
|
@@ -543,6 +589,15 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
543
589
|
onBatchSave: onBatchSave,
|
|
544
590
|
};
|
|
545
591
|
|
|
592
|
+
/** Build a per-group data-table schema (inherits everything except data & pagination). */
|
|
593
|
+
const buildGroupTableSchema = (groupRows: any[]) => ({
|
|
594
|
+
...dataTableSchema,
|
|
595
|
+
caption: undefined,
|
|
596
|
+
data: groupRows,
|
|
597
|
+
pagination: false,
|
|
598
|
+
searchable: false,
|
|
599
|
+
});
|
|
600
|
+
|
|
546
601
|
// Build record detail title
|
|
547
602
|
const detailTitle = schema.label
|
|
548
603
|
? `${schema.label} Detail`
|
|
@@ -550,13 +605,82 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
550
605
|
? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
|
|
551
606
|
: 'Record Detail';
|
|
552
607
|
|
|
608
|
+
// Mobile card-view fallback for screens below 480px
|
|
609
|
+
if (useCardView && data.length > 0 && !isGrouped) {
|
|
610
|
+
const displayColumns = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
|
|
611
|
+
return (
|
|
612
|
+
<>
|
|
613
|
+
<div className="space-y-2 p-2">
|
|
614
|
+
{data.map((row, idx) => (
|
|
615
|
+
<div
|
|
616
|
+
key={row.id || row._id || idx}
|
|
617
|
+
className="border rounded-lg p-3 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation"
|
|
618
|
+
onClick={() => navigation.handleClick(row)}
|
|
619
|
+
>
|
|
620
|
+
{displayColumns.slice(0, 4).map((col: any) => (
|
|
621
|
+
<div key={col.accessorKey} className="flex justify-between items-center py-1">
|
|
622
|
+
<span className="text-xs text-muted-foreground">{col.header}</span>
|
|
623
|
+
<span className="text-sm font-medium truncate ml-2 text-right">
|
|
624
|
+
{col.cell ? col.cell(row[col.accessorKey], row) : String(row[col.accessorKey] ?? '—')}
|
|
625
|
+
</span>
|
|
626
|
+
</div>
|
|
627
|
+
))}
|
|
628
|
+
</div>
|
|
629
|
+
))}
|
|
630
|
+
</div>
|
|
631
|
+
{navigation.isOverlay && (
|
|
632
|
+
<NavigationOverlay {...navigation} title={detailTitle}>
|
|
633
|
+
{(record) => (
|
|
634
|
+
<div className="space-y-3">
|
|
635
|
+
{Object.entries(record).map(([key, value]) => (
|
|
636
|
+
<div key={key} className="flex flex-col">
|
|
637
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
638
|
+
{key.replace(/_/g, ' ')}
|
|
639
|
+
</span>
|
|
640
|
+
<span className="text-sm">{String(value ?? '—')}</span>
|
|
641
|
+
</div>
|
|
642
|
+
))}
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</NavigationOverlay>
|
|
646
|
+
)}
|
|
647
|
+
</>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Render grid content: grouped (multiple tables with headers) or flat (single table)
|
|
652
|
+
const gridContent = isGrouped ? (
|
|
653
|
+
<div className="space-y-2">
|
|
654
|
+
{groups.map((group) => (
|
|
655
|
+
<div key={group.key} className="border rounded-md">
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
|
|
659
|
+
onClick={() => toggleGroup(group.key)}
|
|
660
|
+
>
|
|
661
|
+
{group.collapsed
|
|
662
|
+
? <ChevronRight className="h-4 w-4 shrink-0" />
|
|
663
|
+
: <ChevronDown className="h-4 w-4 shrink-0" />}
|
|
664
|
+
<span>{group.label}</span>
|
|
665
|
+
<span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
|
|
666
|
+
</button>
|
|
667
|
+
{!group.collapsed && (
|
|
668
|
+
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
|
|
669
|
+
)}
|
|
670
|
+
</div>
|
|
671
|
+
))}
|
|
672
|
+
</div>
|
|
673
|
+
) : (
|
|
674
|
+
<SchemaRenderer schema={dataTableSchema} />
|
|
675
|
+
);
|
|
676
|
+
|
|
553
677
|
// For split mode, wrap the grid in the ResizablePanelGroup
|
|
554
678
|
if (navigation.isOverlay && navigation.mode === 'split') {
|
|
555
679
|
return (
|
|
556
680
|
<NavigationOverlay
|
|
557
681
|
{...navigation}
|
|
558
682
|
title={detailTitle}
|
|
559
|
-
mainContent={
|
|
683
|
+
mainContent={gridContent}
|
|
560
684
|
>
|
|
561
685
|
{(record) => (
|
|
562
686
|
<div className="space-y-3">
|
|
@@ -575,8 +699,16 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
575
699
|
}
|
|
576
700
|
|
|
577
701
|
return (
|
|
578
|
-
|
|
579
|
-
|
|
702
|
+
<div ref={pullRef} className="relative h-full">
|
|
703
|
+
{pullDistance > 0 && (
|
|
704
|
+
<div
|
|
705
|
+
className="flex items-center justify-center text-xs text-muted-foreground"
|
|
706
|
+
style={{ height: pullDistance }}
|
|
707
|
+
>
|
|
708
|
+
{isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
{gridContent}
|
|
580
712
|
{navigation.isOverlay && (
|
|
581
713
|
<NavigationOverlay
|
|
582
714
|
{...navigation}
|
|
@@ -596,6 +728,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
596
728
|
)}
|
|
597
729
|
</NavigationOverlay>
|
|
598
730
|
)}
|
|
599
|
-
|
|
731
|
+
</div>
|
|
600
732
|
);
|
|
601
733
|
};
|