@object-ui/plugin-grid 0.5.0 → 3.0.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 +51 -6
- package/CHANGELOG.md +37 -0
- package/README.md +97 -0
- package/dist/index.js +994 -584
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
- 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__/VirtualGrid.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/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.msw.test.tsx +24 -1
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +409 -113
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/VirtualGrid.test.tsx +438 -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 +17 -2
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* InlineEditing Component
|
|
11
|
+
*
|
|
12
|
+
* A reusable inline cell editor for grid views. Provides save/cancel
|
|
13
|
+
* controls and validation feedback. Designed to be integrated into
|
|
14
|
+
* ObjectGrid or VirtualGrid rows.
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Click-to-edit with automatic focus
|
|
18
|
+
* - Save (Enter / ✓) and Cancel (Escape / ✗) actions
|
|
19
|
+
* - Validation feedback with error messages
|
|
20
|
+
* - Keyboard navigation (Enter to save, Escape to cancel)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
24
|
+
import { cn } from '@object-ui/components';
|
|
25
|
+
import { Check, X } from 'lucide-react';
|
|
26
|
+
|
|
27
|
+
export interface InlineEditingProps {
|
|
28
|
+
/** Current cell value */
|
|
29
|
+
value: any;
|
|
30
|
+
/** Called with new value on save. Return a string to signal validation error. */
|
|
31
|
+
onSave: (newValue: any) => void | string | Promise<void | string>;
|
|
32
|
+
/** Called when editing is cancelled */
|
|
33
|
+
onCancel?: () => void;
|
|
34
|
+
/** Validate before saving. Return error message or undefined. */
|
|
35
|
+
validate?: (value: any) => string | undefined;
|
|
36
|
+
/** Field type hint used to determine input type */
|
|
37
|
+
type?: 'text' | 'number' | 'email';
|
|
38
|
+
/** Placeholder text */
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
/** Whether the cell is in editing mode initially */
|
|
41
|
+
editing?: boolean;
|
|
42
|
+
/** Additional class names */
|
|
43
|
+
className?: string;
|
|
44
|
+
/** Whether this field is disabled */
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function InlineEditing({
|
|
49
|
+
value,
|
|
50
|
+
onSave,
|
|
51
|
+
onCancel,
|
|
52
|
+
validate,
|
|
53
|
+
type = 'text',
|
|
54
|
+
placeholder,
|
|
55
|
+
editing: editingProp = false,
|
|
56
|
+
className,
|
|
57
|
+
disabled = false,
|
|
58
|
+
}: InlineEditingProps) {
|
|
59
|
+
const [isEditing, setIsEditing] = useState(editingProp);
|
|
60
|
+
const [editValue, setEditValue] = useState<string>(String(value ?? ''));
|
|
61
|
+
const [error, setError] = useState<string | undefined>();
|
|
62
|
+
const [saving, setSaving] = useState(false);
|
|
63
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
|
|
65
|
+
// Sync with prop changes
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!isEditing) {
|
|
68
|
+
setEditValue(String(value ?? ''));
|
|
69
|
+
}
|
|
70
|
+
}, [value, isEditing]);
|
|
71
|
+
|
|
72
|
+
// Auto-focus when entering edit mode
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (isEditing && inputRef.current) {
|
|
75
|
+
inputRef.current.focus();
|
|
76
|
+
inputRef.current.select();
|
|
77
|
+
}
|
|
78
|
+
}, [isEditing]);
|
|
79
|
+
|
|
80
|
+
const startEditing = useCallback(() => {
|
|
81
|
+
if (disabled) return;
|
|
82
|
+
setIsEditing(true);
|
|
83
|
+
setEditValue(String(value ?? ''));
|
|
84
|
+
setError(undefined);
|
|
85
|
+
}, [disabled, value]);
|
|
86
|
+
|
|
87
|
+
const cancel = useCallback(() => {
|
|
88
|
+
setIsEditing(false);
|
|
89
|
+
setEditValue(String(value ?? ''));
|
|
90
|
+
setError(undefined);
|
|
91
|
+
onCancel?.();
|
|
92
|
+
}, [value, onCancel]);
|
|
93
|
+
|
|
94
|
+
const save = useCallback(async () => {
|
|
95
|
+
// Run validation
|
|
96
|
+
if (validate) {
|
|
97
|
+
const validationError = validate(editValue);
|
|
98
|
+
if (validationError) {
|
|
99
|
+
setError(validationError);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Coerce number values
|
|
105
|
+
const coercedValue = type === 'number' ? Number(editValue) : editValue;
|
|
106
|
+
|
|
107
|
+
setSaving(true);
|
|
108
|
+
try {
|
|
109
|
+
const result = await onSave(coercedValue);
|
|
110
|
+
if (typeof result === 'string') {
|
|
111
|
+
setError(result);
|
|
112
|
+
setSaving(false);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setIsEditing(false);
|
|
116
|
+
setError(undefined);
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
setError(err?.message || 'Save failed');
|
|
119
|
+
} finally {
|
|
120
|
+
setSaving(false);
|
|
121
|
+
}
|
|
122
|
+
}, [editValue, validate, type, onSave]);
|
|
123
|
+
|
|
124
|
+
const handleKeyDown = useCallback(
|
|
125
|
+
(e: React.KeyboardEvent) => {
|
|
126
|
+
if (e.key === 'Enter') {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
save();
|
|
129
|
+
} else if (e.key === 'Escape') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
cancel();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[save, cancel],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Display mode
|
|
138
|
+
if (!isEditing) {
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
data-slot="inline-editing"
|
|
142
|
+
className={cn(
|
|
143
|
+
'group relative cursor-pointer rounded px-2 py-1 hover:bg-muted/50 transition-colors min-h-[1.75rem] flex items-center',
|
|
144
|
+
disabled && 'cursor-default opacity-60',
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
onClick={startEditing}
|
|
148
|
+
role="button"
|
|
149
|
+
tabIndex={disabled ? -1 : 0}
|
|
150
|
+
onKeyDown={(e) => {
|
|
151
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
startEditing();
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
aria-label={`Edit value: ${String(value ?? '')}`}
|
|
157
|
+
>
|
|
158
|
+
<span data-slot="inline-editing-display" className="truncate text-sm">
|
|
159
|
+
{value != null && String(value) !== '' ? String(value) : (
|
|
160
|
+
<span className="text-muted-foreground italic">{placeholder || 'Click to edit'}</span>
|
|
161
|
+
)}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Edit mode
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
data-slot="inline-editing"
|
|
171
|
+
className={cn('relative flex items-center gap-1', className)}
|
|
172
|
+
>
|
|
173
|
+
<div className="flex-1 relative">
|
|
174
|
+
<input
|
|
175
|
+
ref={inputRef}
|
|
176
|
+
data-slot="inline-editing-input"
|
|
177
|
+
type={type}
|
|
178
|
+
value={editValue}
|
|
179
|
+
onChange={(e) => {
|
|
180
|
+
setEditValue(e.target.value);
|
|
181
|
+
if (error) setError(undefined);
|
|
182
|
+
}}
|
|
183
|
+
onKeyDown={handleKeyDown}
|
|
184
|
+
placeholder={placeholder}
|
|
185
|
+
disabled={saving}
|
|
186
|
+
aria-invalid={!!error}
|
|
187
|
+
aria-describedby={error ? 'inline-editing-error' : undefined}
|
|
188
|
+
className={cn(
|
|
189
|
+
'w-full rounded border px-2 py-1 text-sm outline-none transition-colors',
|
|
190
|
+
'focus:ring-2 focus:ring-ring focus:border-input',
|
|
191
|
+
error
|
|
192
|
+
? 'border-destructive focus:ring-destructive/30'
|
|
193
|
+
: 'border-input',
|
|
194
|
+
saving && 'opacity-50',
|
|
195
|
+
)}
|
|
196
|
+
/>
|
|
197
|
+
{error && (
|
|
198
|
+
<p
|
|
199
|
+
id="inline-editing-error"
|
|
200
|
+
data-slot="inline-editing-error"
|
|
201
|
+
className="absolute left-0 top-full mt-0.5 text-xs text-destructive"
|
|
202
|
+
role="alert"
|
|
203
|
+
>
|
|
204
|
+
{error}
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Action buttons */}
|
|
210
|
+
<button
|
|
211
|
+
data-slot="inline-editing-save"
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={save}
|
|
214
|
+
disabled={saving}
|
|
215
|
+
aria-label="Save"
|
|
216
|
+
className={cn(
|
|
217
|
+
'inline-flex h-6 w-6 items-center justify-center rounded text-primary hover:bg-primary/10 transition-colors',
|
|
218
|
+
saving && 'opacity-50 cursor-not-allowed',
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
<Check className="h-3.5 w-3.5" />
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
data-slot="inline-editing-cancel"
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={cancel}
|
|
227
|
+
disabled={saving}
|
|
228
|
+
aria-label="Cancel"
|
|
229
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
|
230
|
+
>
|
|
231
|
+
<X className="h-3.5 w-3.5" />
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListColumn Extensions Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for link, action, hidden, type, wrap, and resizable properties
|
|
5
|
+
* on ListColumn when rendered through ObjectGrid → data-table.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
9
|
+
import '@testing-library/jest-dom';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { ObjectGrid } from './ObjectGrid';
|
|
12
|
+
import { registerAllFields } from '@object-ui/fields';
|
|
13
|
+
import { ActionProvider } from '@object-ui/react';
|
|
14
|
+
import type { ListColumn } from '@object-ui/types';
|
|
15
|
+
|
|
16
|
+
registerAllFields();
|
|
17
|
+
|
|
18
|
+
// --- Mock Data ---
|
|
19
|
+
const mockData = [
|
|
20
|
+
{ _id: '1', name: 'Alice', email: 'alice@test.com', amount: 1500, status: 'active' },
|
|
21
|
+
{ _id: '2', name: 'Bob', email: 'bob@test.com', amount: 2300, status: 'inactive' },
|
|
22
|
+
{ _id: '3', name: 'Charlie', email: 'charlie@test.com', amount: 800, status: 'active' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// --- Helper: Render ObjectGrid with static data and ListColumn[] ---
|
|
26
|
+
function renderGrid(columns: ListColumn[], opts?: { onNavigate?: any; navigation?: any }) {
|
|
27
|
+
const schema: any = {
|
|
28
|
+
type: 'object-grid' as const,
|
|
29
|
+
objectName: 'test_object',
|
|
30
|
+
columns,
|
|
31
|
+
data: { provider: 'value', items: mockData },
|
|
32
|
+
navigation: opts?.navigation,
|
|
33
|
+
onNavigate: opts?.onNavigate,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return render(
|
|
37
|
+
<ActionProvider>
|
|
38
|
+
<ObjectGrid schema={schema} />
|
|
39
|
+
</ActionProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =========================================================================
|
|
44
|
+
// 1. Hidden columns
|
|
45
|
+
// =========================================================================
|
|
46
|
+
describe('ListColumn: hidden', () => {
|
|
47
|
+
it('should not render hidden columns', async () => {
|
|
48
|
+
renderGrid([
|
|
49
|
+
{ field: 'name', label: 'Name' },
|
|
50
|
+
{ field: 'email', label: 'Email', hidden: true },
|
|
51
|
+
{ field: 'amount', label: 'Amount' },
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
await waitFor(() => {
|
|
55
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
expect(screen.getByText('Amount')).toBeInTheDocument();
|
|
58
|
+
// Email column should NOT be rendered
|
|
59
|
+
expect(screen.queryByText('Email')).not.toBeInTheDocument();
|
|
60
|
+
expect(screen.queryByText('alice@test.com')).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should render non-hidden columns normally', async () => {
|
|
64
|
+
renderGrid([
|
|
65
|
+
{ field: 'name', label: 'Name', hidden: false },
|
|
66
|
+
{ field: 'email', label: 'Email' },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// =========================================================================
|
|
77
|
+
// 2. Link columns
|
|
78
|
+
// =========================================================================
|
|
79
|
+
describe('ListColumn: link', () => {
|
|
80
|
+
it('should render link columns as clickable text', async () => {
|
|
81
|
+
renderGrid([
|
|
82
|
+
{ field: 'name', label: 'Name', link: true },
|
|
83
|
+
{ field: 'email', label: 'Email' },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// The name cells should be rendered as buttons (clickable links)
|
|
91
|
+
const aliceLink = screen.getByRole('button', { name: 'Alice' });
|
|
92
|
+
expect(aliceLink).toBeInTheDocument();
|
|
93
|
+
expect(aliceLink).toHaveClass('text-primary');
|
|
94
|
+
|
|
95
|
+
const bobLink = screen.getByRole('button', { name: 'Bob' });
|
|
96
|
+
expect(bobLink).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should trigger navigation when link column is clicked', async () => {
|
|
100
|
+
const onNavigate = vi.fn();
|
|
101
|
+
|
|
102
|
+
renderGrid(
|
|
103
|
+
[
|
|
104
|
+
{ field: 'name', label: 'Name', link: true },
|
|
105
|
+
{ field: 'email', label: 'Email' },
|
|
106
|
+
],
|
|
107
|
+
{
|
|
108
|
+
navigation: { mode: 'page' },
|
|
109
|
+
onNavigate,
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(screen.getByRole('button', { name: 'Alice' })).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
fireEvent.click(screen.getByRole('button', { name: 'Alice' }));
|
|
118
|
+
|
|
119
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(onNavigate).toHaveBeenCalledWith('1', 'view');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should not render non-link columns as buttons', async () => {
|
|
124
|
+
renderGrid([
|
|
125
|
+
{ field: 'name', label: 'Name', link: true },
|
|
126
|
+
{ field: 'email', label: 'Email' },
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Email values should NOT be buttons
|
|
134
|
+
expect(screen.queryByRole('button', { name: 'alice@test.com' })).not.toBeInTheDocument();
|
|
135
|
+
// But the text should still render
|
|
136
|
+
expect(screen.getByText('alice@test.com')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// =========================================================================
|
|
141
|
+
// 3. Action columns
|
|
142
|
+
// =========================================================================
|
|
143
|
+
describe('ListColumn: action', () => {
|
|
144
|
+
it('should render action columns as clickable text', async () => {
|
|
145
|
+
renderGrid([
|
|
146
|
+
{ field: 'name', label: 'Name' },
|
|
147
|
+
{ field: 'status', label: 'Status', action: 'toggleStatus' },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Status cells should be buttons
|
|
155
|
+
const activeBtn = screen.getAllByRole('button', { name: 'active' });
|
|
156
|
+
expect(activeBtn.length).toBeGreaterThanOrEqual(1);
|
|
157
|
+
expect(activeBtn[0]).toHaveClass('text-primary');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should execute action when action column is clicked', async () => {
|
|
161
|
+
const actionHandler = vi.fn().mockResolvedValue({ success: true });
|
|
162
|
+
|
|
163
|
+
const schema: any = {
|
|
164
|
+
type: 'object-grid' as const,
|
|
165
|
+
objectName: 'test_object',
|
|
166
|
+
columns: [
|
|
167
|
+
{ field: 'name', label: 'Name' },
|
|
168
|
+
{ field: 'status', label: 'Status', action: 'toggleStatus' },
|
|
169
|
+
],
|
|
170
|
+
data: { provider: 'value', items: mockData },
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
render(
|
|
174
|
+
<ActionProvider handlers={{ toggleStatus: actionHandler }}>
|
|
175
|
+
<ObjectGrid schema={schema} />
|
|
176
|
+
</ActionProvider>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await waitFor(() => {
|
|
180
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const statusBtns = screen.getAllByRole('button', { name: 'active' });
|
|
184
|
+
fireEvent.click(statusBtns[0]);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(actionHandler).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
expect(actionHandler).toHaveBeenCalledWith(
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
type: 'toggleStatus',
|
|
192
|
+
params: expect.objectContaining({
|
|
193
|
+
field: 'status',
|
|
194
|
+
value: 'active',
|
|
195
|
+
record: expect.objectContaining({ _id: '1', name: 'Alice' }),
|
|
196
|
+
}),
|
|
197
|
+
}),
|
|
198
|
+
expect.any(Object) // ActionCtx
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// =========================================================================
|
|
204
|
+
// 4. Type-based cell rendering
|
|
205
|
+
// =========================================================================
|
|
206
|
+
describe('ListColumn: type', () => {
|
|
207
|
+
it('should use getCellRenderer for typed columns', async () => {
|
|
208
|
+
renderGrid([
|
|
209
|
+
{ field: 'name', label: 'Name' },
|
|
210
|
+
{ field: 'email', label: 'Email', type: 'email' },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Email type should render as a mailto link
|
|
218
|
+
const emailLink = screen.getByText('alice@test.com');
|
|
219
|
+
expect(emailLink).toBeInTheDocument();
|
|
220
|
+
// The email cell renderer wraps in an anchor
|
|
221
|
+
expect(emailLink.closest('a')).toHaveAttribute('href', 'mailto:alice@test.com');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should render boolean type columns correctly', async () => {
|
|
225
|
+
const boolData = [
|
|
226
|
+
{ _id: '1', name: 'Alice', active: true },
|
|
227
|
+
{ _id: '2', name: 'Bob', active: false },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const schema: any = {
|
|
231
|
+
type: 'object-grid' as const,
|
|
232
|
+
objectName: 'test_object',
|
|
233
|
+
columns: [
|
|
234
|
+
{ field: 'name', label: 'Name' },
|
|
235
|
+
{ field: 'active', label: 'Active', type: 'boolean' },
|
|
236
|
+
],
|
|
237
|
+
data: { provider: 'value', items: boolData },
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
render(
|
|
241
|
+
<ActionProvider>
|
|
242
|
+
<ObjectGrid schema={schema} />
|
|
243
|
+
</ActionProvider>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Boolean renderer should show check/x icons or text representation
|
|
251
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// =========================================================================
|
|
256
|
+
// 5. Combined: link + type
|
|
257
|
+
// =========================================================================
|
|
258
|
+
describe('ListColumn: link + type', () => {
|
|
259
|
+
it('should render typed content inside a clickable link', async () => {
|
|
260
|
+
const onNavigate = vi.fn();
|
|
261
|
+
|
|
262
|
+
renderGrid(
|
|
263
|
+
[
|
|
264
|
+
{ field: 'email', label: 'Email', link: true, type: 'email' },
|
|
265
|
+
{ field: 'name', label: 'Name' },
|
|
266
|
+
],
|
|
267
|
+
{
|
|
268
|
+
navigation: { mode: 'page' },
|
|
269
|
+
onNavigate,
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await waitFor(() => {
|
|
274
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Should be a button wrapping the email content
|
|
278
|
+
const emailBtn = screen.getByRole('button', { name: /alice@test.com/ });
|
|
279
|
+
expect(emailBtn).toBeInTheDocument();
|
|
280
|
+
expect(emailBtn).toHaveClass('text-primary');
|
|
281
|
+
|
|
282
|
+
fireEvent.click(emailBtn);
|
|
283
|
+
expect(onNavigate).toHaveBeenCalledWith('1', 'view');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// =========================================================================
|
|
288
|
+
// 6. Column properties passthrough
|
|
289
|
+
// =========================================================================
|
|
290
|
+
describe('ListColumn: property passthrough', () => {
|
|
291
|
+
it('should auto-generate header from field name if no label', async () => {
|
|
292
|
+
renderGrid([
|
|
293
|
+
{ field: 'first_name' },
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
// Should convert snake_case to title case
|
|
298
|
+
expect(screen.getByText('First name')).toBeInTheDocument();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should use label when provided', async () => {
|
|
303
|
+
renderGrid([
|
|
304
|
+
{ field: 'name', label: 'Full Name' },
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
await waitFor(() => {
|
|
308
|
+
expect(screen.getByText('Full Name')).toBeInTheDocument();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should handle all columns hidden gracefully', async () => {
|
|
313
|
+
const { container } = renderGrid([
|
|
314
|
+
{ field: 'name', hidden: true },
|
|
315
|
+
{ field: 'email', hidden: true },
|
|
316
|
+
]);
|
|
317
|
+
|
|
318
|
+
// Should render without error, just no columns
|
|
319
|
+
await waitFor(() => {
|
|
320
|
+
expect(container).toBeInTheDocument();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// =========================================================================
|
|
326
|
+
// 7. Type definitions alignment
|
|
327
|
+
// =========================================================================
|
|
328
|
+
describe('ListColumn type definitions', () => {
|
|
329
|
+
it('should accept link property on ListColumn', () => {
|
|
330
|
+
const col: ListColumn = {
|
|
331
|
+
field: 'name',
|
|
332
|
+
link: true,
|
|
333
|
+
};
|
|
334
|
+
expect(col.link).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should accept action property on ListColumn', () => {
|
|
338
|
+
const col: ListColumn = {
|
|
339
|
+
field: 'status',
|
|
340
|
+
action: 'toggleStatus',
|
|
341
|
+
};
|
|
342
|
+
expect(col.action).toBe('toggleStatus');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should accept both link and action together', () => {
|
|
346
|
+
const col: ListColumn = {
|
|
347
|
+
field: 'name',
|
|
348
|
+
link: true,
|
|
349
|
+
action: 'viewDetail',
|
|
350
|
+
};
|
|
351
|
+
expect(col.link).toBe(true);
|
|
352
|
+
expect(col.action).toBe('viewDetail');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should accept all ListColumn properties', () => {
|
|
356
|
+
const col: ListColumn = {
|
|
357
|
+
field: 'amount',
|
|
358
|
+
label: 'Total Amount',
|
|
359
|
+
width: 150,
|
|
360
|
+
align: 'right',
|
|
361
|
+
hidden: false,
|
|
362
|
+
sortable: true,
|
|
363
|
+
resizable: true,
|
|
364
|
+
wrap: false,
|
|
365
|
+
type: 'currency',
|
|
366
|
+
link: false,
|
|
367
|
+
action: 'editAmount',
|
|
368
|
+
};
|
|
369
|
+
expect(col.field).toBe('amount');
|
|
370
|
+
expect(col.type).toBe('currency');
|
|
371
|
+
expect(col.link).toBe(false);
|
|
372
|
+
expect(col.action).toBe('editAmount');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListColumn Zod Schema Tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the ListColumnSchema Zod definition includes link and action properties.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { ListColumnSchema } from '@object-ui/types/zod';
|
|
8
|
+
|
|
9
|
+
describe('ListColumnSchema (Zod)', () => {
|
|
10
|
+
it('should accept link property', () => {
|
|
11
|
+
const result = ListColumnSchema.safeParse({
|
|
12
|
+
field: 'name',
|
|
13
|
+
link: true,
|
|
14
|
+
});
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
if (result.success) {
|
|
17
|
+
expect(result.data.link).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should accept action property', () => {
|
|
22
|
+
const result = ListColumnSchema.safeParse({
|
|
23
|
+
field: 'status',
|
|
24
|
+
action: 'toggleStatus',
|
|
25
|
+
});
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
if (result.success) {
|
|
28
|
+
expect(result.data.action).toBe('toggleStatus');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should accept all properties together', () => {
|
|
33
|
+
const result = ListColumnSchema.safeParse({
|
|
34
|
+
field: 'name',
|
|
35
|
+
label: 'Full Name',
|
|
36
|
+
width: 200,
|
|
37
|
+
align: 'left',
|
|
38
|
+
hidden: false,
|
|
39
|
+
sortable: true,
|
|
40
|
+
resizable: true,
|
|
41
|
+
wrap: false,
|
|
42
|
+
type: 'text',
|
|
43
|
+
link: true,
|
|
44
|
+
action: 'viewDetail',
|
|
45
|
+
});
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
if (result.success) {
|
|
48
|
+
expect(result.data.link).toBe(true);
|
|
49
|
+
expect(result.data.action).toBe('viewDetail');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should make link optional', () => {
|
|
54
|
+
const result = ListColumnSchema.safeParse({
|
|
55
|
+
field: 'name',
|
|
56
|
+
});
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
if (result.success) {
|
|
59
|
+
expect(result.data.link).toBeUndefined();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should make action optional', () => {
|
|
64
|
+
const result = ListColumnSchema.safeParse({
|
|
65
|
+
field: 'name',
|
|
66
|
+
});
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
if (result.success) {
|
|
69
|
+
expect(result.data.action).toBeUndefined();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should reject non-boolean link', () => {
|
|
74
|
+
const result = ListColumnSchema.safeParse({
|
|
75
|
+
field: 'name',
|
|
76
|
+
link: 'yes',
|
|
77
|
+
});
|
|
78
|
+
expect(result.success).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should reject non-string action', () => {
|
|
82
|
+
const result = ListColumnSchema.safeParse({
|
|
83
|
+
field: 'name',
|
|
84
|
+
action: 42,
|
|
85
|
+
});
|
|
86
|
+
expect(result.success).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|