@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.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +51 -6
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +97 -0
  4. package/dist/index.js +994 -584
  5. package/dist/index.umd.cjs +3 -3
  6. package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
  7. package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
  8. package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
  9. package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
  10. package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
  11. package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
  12. package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
  13. package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
  14. package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
  15. package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
  16. package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
  17. package/dist/packages/plugin-grid/src/index.d.ts +5 -0
  18. package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
  19. package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
  20. package/package.json +11 -10
  21. package/src/InlineEditing.tsx +235 -0
  22. package/src/ListColumnExtensions.test.tsx +374 -0
  23. package/src/ListColumnSchema.test.ts +88 -0
  24. package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
  25. package/src/ObjectGrid.msw.test.tsx +24 -1
  26. package/src/ObjectGrid.stories.tsx +139 -0
  27. package/src/ObjectGrid.tsx +409 -113
  28. package/src/__tests__/InlineEditing.test.tsx +360 -0
  29. package/src/__tests__/VirtualGrid.test.tsx +438 -0
  30. package/src/__tests__/accessibility.test.tsx +254 -0
  31. package/src/__tests__/performance-benchmark.test.tsx +182 -0
  32. package/src/__tests__/view-states.test.tsx +203 -0
  33. package/src/index.tsx +17 -2
  34. package/src/useGroupedData.ts +122 -0
  35. 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
+ });