@object-ui/components 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +24932 -23139
  5. package/dist/index.umd.cjs +37 -37
  6. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  7. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  8. package/dist/src/custom/config-row.d.ts +27 -0
  9. package/dist/src/custom/filter-builder.d.ts +1 -1
  10. package/dist/src/custom/index.d.ts +5 -0
  11. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  12. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  13. package/dist/src/custom/section-header.d.ts +31 -0
  14. package/dist/src/debug/DebugPanel.d.ts +39 -0
  15. package/dist/src/debug/index.d.ts +9 -0
  16. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  17. package/dist/src/index.d.ts +4 -0
  18. package/dist/src/renderers/action/action-bar.d.ts +25 -0
  19. package/dist/src/renderers/action/action-button.d.ts +1 -0
  20. package/dist/src/types/config-panel.d.ts +92 -0
  21. package/dist/src/ui/sheet.d.ts +2 -0
  22. package/dist/src/ui/sidebar.d.ts +4 -0
  23. package/package.json +17 -17
  24. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  25. package/src/__tests__/action-bar.test.tsx +206 -0
  26. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  27. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  28. package/src/__tests__/config-primitives.test.tsx +106 -0
  29. package/src/__tests__/filter-builder.test.tsx +409 -0
  30. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  31. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  32. package/src/__tests__/use-config-draft.test.tsx +295 -0
  33. package/src/custom/config-field-renderer.tsx +276 -0
  34. package/src/custom/config-panel-renderer.tsx +306 -0
  35. package/src/custom/config-row.tsx +50 -0
  36. package/src/custom/filter-builder.tsx +76 -25
  37. package/src/custom/index.ts +5 -0
  38. package/src/custom/mobile-dialog-content.tsx +67 -0
  39. package/src/custom/navigation-overlay.tsx +42 -4
  40. package/src/custom/section-header.tsx +68 -0
  41. package/src/debug/DebugPanel.tsx +313 -0
  42. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  43. package/src/debug/index.ts +10 -0
  44. package/src/hooks/use-config-draft.ts +127 -0
  45. package/src/index.css +4 -0
  46. package/src/index.ts +15 -0
  47. package/src/renderers/action/action-bar.tsx +221 -0
  48. package/src/renderers/action/action-button.tsx +17 -6
  49. package/src/renderers/action/index.ts +1 -0
  50. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  51. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  52. package/src/renderers/complex/data-table.tsx +346 -43
  53. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  54. package/src/renderers/form/form.tsx +4 -4
  55. package/src/renderers/navigation/header-bar.tsx +69 -10
  56. package/src/stories/ConfigPanel.stories.tsx +232 -0
  57. package/src/types/config-panel.ts +101 -0
  58. package/src/ui/dialog.tsx +20 -3
  59. package/src/ui/sheet.tsx +6 -3
  60. package/src/ui/sidebar.tsx +93 -9
@@ -0,0 +1,295 @@
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, vi } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useConfigDraft } from '../hooks/use-config-draft';
12
+
13
+ describe('useConfigDraft', () => {
14
+ const source = { name: 'Test', columns: 3, gap: 4, enabled: true };
15
+
16
+ it('should initialize draft from source', () => {
17
+ const { result } = renderHook(() => useConfigDraft(source));
18
+ expect(result.current.draft).toEqual(source);
19
+ expect(result.current.isDirty).toBe(false);
20
+ });
21
+
22
+ it('should start dirty in create mode', () => {
23
+ const { result } = renderHook(() =>
24
+ useConfigDraft(source, { mode: 'create' }),
25
+ );
26
+ expect(result.current.isDirty).toBe(true);
27
+ });
28
+
29
+ it('should start clean in edit mode', () => {
30
+ const { result } = renderHook(() =>
31
+ useConfigDraft(source, { mode: 'edit' }),
32
+ );
33
+ expect(result.current.isDirty).toBe(false);
34
+ });
35
+
36
+ it('should update a single field and mark dirty', () => {
37
+ const { result } = renderHook(() => useConfigDraft(source));
38
+ act(() => {
39
+ result.current.updateField('columns', 6);
40
+ });
41
+ expect(result.current.draft.columns).toBe(6);
42
+ expect(result.current.isDirty).toBe(true);
43
+ });
44
+
45
+ it('should preserve other fields when updating one', () => {
46
+ const { result } = renderHook(() => useConfigDraft(source));
47
+ act(() => {
48
+ result.current.updateField('gap', 8);
49
+ });
50
+ expect(result.current.draft.name).toBe('Test');
51
+ expect(result.current.draft.columns).toBe(3);
52
+ expect(result.current.draft.gap).toBe(8);
53
+ });
54
+
55
+ it('should discard changes and revert to source', () => {
56
+ const { result } = renderHook(() => useConfigDraft(source));
57
+ act(() => {
58
+ result.current.updateField('name', 'Modified');
59
+ result.current.updateField('columns', 12);
60
+ });
61
+ expect(result.current.isDirty).toBe(true);
62
+
63
+ act(() => {
64
+ result.current.discard();
65
+ });
66
+ expect(result.current.draft).toEqual(source);
67
+ expect(result.current.isDirty).toBe(false);
68
+ });
69
+
70
+ it('should call onUpdate callback when field changes', () => {
71
+ const onUpdate = vi.fn();
72
+ const { result } = renderHook(() =>
73
+ useConfigDraft(source, { onUpdate }),
74
+ );
75
+ act(() => {
76
+ result.current.updateField('gap', 10);
77
+ });
78
+ expect(onUpdate).toHaveBeenCalledWith('gap', 10);
79
+ expect(onUpdate).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('should reset draft when source changes', () => {
83
+ const source1 = { name: 'A', value: 1 };
84
+ const source2 = { name: 'B', value: 2 };
85
+
86
+ const { result, rerender } = renderHook(
87
+ ({ src }) => useConfigDraft(src),
88
+ { initialProps: { src: source1 } },
89
+ );
90
+
91
+ act(() => {
92
+ result.current.updateField('name', 'Modified');
93
+ });
94
+ expect(result.current.isDirty).toBe(true);
95
+
96
+ rerender({ src: source2 });
97
+ expect(result.current.draft).toEqual(source2);
98
+ expect(result.current.isDirty).toBe(false);
99
+ });
100
+
101
+ it('should handle setDraft for advanced use cases', () => {
102
+ const { result } = renderHook(() => useConfigDraft(source));
103
+ act(() => {
104
+ result.current.setDraft({ ...source, name: 'Advanced', extra: 42 });
105
+ });
106
+ expect(result.current.draft.name).toBe('Advanced');
107
+ expect(result.current.draft.extra).toBe(42);
108
+ });
109
+
110
+ it('should not share reference with source', () => {
111
+ const mutableSource = { name: 'Original' };
112
+ const { result } = renderHook(() => useConfigDraft(mutableSource));
113
+ expect(result.current.draft).not.toBe(mutableSource);
114
+ expect(result.current.draft).toEqual(mutableSource);
115
+ });
116
+
117
+ // ── Undo / Redo ──────────────────────────────────────────────
118
+
119
+ describe('undo/redo', () => {
120
+ it('should start with canUndo=false and canRedo=false', () => {
121
+ const { result } = renderHook(() => useConfigDraft(source));
122
+ expect(result.current.canUndo).toBe(false);
123
+ expect(result.current.canRedo).toBe(false);
124
+ });
125
+
126
+ it('should enable undo after a field update', () => {
127
+ const { result } = renderHook(() => useConfigDraft(source));
128
+ act(() => {
129
+ result.current.updateField('name', 'Changed');
130
+ });
131
+ expect(result.current.canUndo).toBe(true);
132
+ expect(result.current.canRedo).toBe(false);
133
+ });
134
+
135
+ it('should undo to previous state', () => {
136
+ const { result } = renderHook(() => useConfigDraft(source));
137
+ act(() => {
138
+ result.current.updateField('name', 'Changed');
139
+ });
140
+ expect(result.current.draft.name).toBe('Changed');
141
+
142
+ act(() => {
143
+ result.current.undo();
144
+ });
145
+ expect(result.current.draft.name).toBe('Test');
146
+ expect(result.current.canUndo).toBe(false);
147
+ expect(result.current.canRedo).toBe(true);
148
+ });
149
+
150
+ it('should redo after undo', () => {
151
+ const { result } = renderHook(() => useConfigDraft(source));
152
+ act(() => {
153
+ result.current.updateField('name', 'Changed');
154
+ });
155
+ act(() => {
156
+ result.current.undo();
157
+ });
158
+ act(() => {
159
+ result.current.redo();
160
+ });
161
+ expect(result.current.draft.name).toBe('Changed');
162
+ expect(result.current.canUndo).toBe(true);
163
+ expect(result.current.canRedo).toBe(false);
164
+ });
165
+
166
+ it('should clear redo stack when a new change is made after undo', () => {
167
+ const { result } = renderHook(() => useConfigDraft(source));
168
+ act(() => {
169
+ result.current.updateField('name', 'First');
170
+ });
171
+ act(() => {
172
+ result.current.updateField('name', 'Second');
173
+ });
174
+ act(() => {
175
+ result.current.undo();
176
+ });
177
+ expect(result.current.canRedo).toBe(true);
178
+
179
+ act(() => {
180
+ result.current.updateField('name', 'Third');
181
+ });
182
+ expect(result.current.canRedo).toBe(false);
183
+ expect(result.current.draft.name).toBe('Third');
184
+ });
185
+
186
+ it('should support multiple undo steps', () => {
187
+ const { result } = renderHook(() => useConfigDraft(source));
188
+ act(() => {
189
+ result.current.updateField('name', 'A');
190
+ });
191
+ act(() => {
192
+ result.current.updateField('name', 'B');
193
+ });
194
+ act(() => {
195
+ result.current.updateField('name', 'C');
196
+ });
197
+ expect(result.current.draft.name).toBe('C');
198
+
199
+ act(() => {
200
+ result.current.undo();
201
+ });
202
+ expect(result.current.draft.name).toBe('B');
203
+
204
+ act(() => {
205
+ result.current.undo();
206
+ });
207
+ expect(result.current.draft.name).toBe('A');
208
+
209
+ act(() => {
210
+ result.current.undo();
211
+ });
212
+ expect(result.current.draft.name).toBe('Test');
213
+ expect(result.current.canUndo).toBe(false);
214
+ });
215
+
216
+ it('should do nothing when undoing with empty history', () => {
217
+ const { result } = renderHook(() => useConfigDraft(source));
218
+ act(() => {
219
+ result.current.undo();
220
+ });
221
+ expect(result.current.draft).toEqual(source);
222
+ });
223
+
224
+ it('should do nothing when redoing with empty future', () => {
225
+ const { result } = renderHook(() => useConfigDraft(source));
226
+ act(() => {
227
+ result.current.redo();
228
+ });
229
+ expect(result.current.draft).toEqual(source);
230
+ });
231
+
232
+ it('should clear history on discard', () => {
233
+ const { result } = renderHook(() => useConfigDraft(source));
234
+ act(() => {
235
+ result.current.updateField('name', 'Changed');
236
+ });
237
+ expect(result.current.canUndo).toBe(true);
238
+
239
+ act(() => {
240
+ result.current.discard();
241
+ });
242
+ expect(result.current.canUndo).toBe(false);
243
+ expect(result.current.canRedo).toBe(false);
244
+ });
245
+
246
+ it('should clear history when source changes', () => {
247
+ const source1 = { name: 'A', value: 1 };
248
+ const source2 = { name: 'B', value: 2 };
249
+ const { result, rerender } = renderHook(
250
+ ({ src }) => useConfigDraft(src),
251
+ { initialProps: { src: source1 } },
252
+ );
253
+ act(() => {
254
+ result.current.updateField('name', 'Modified');
255
+ });
256
+ expect(result.current.canUndo).toBe(true);
257
+
258
+ rerender({ src: source2 });
259
+ expect(result.current.canUndo).toBe(false);
260
+ expect(result.current.canRedo).toBe(false);
261
+ });
262
+
263
+ it('should respect maxHistory limit', () => {
264
+ const { result } = renderHook(() =>
265
+ useConfigDraft(source, { maxHistory: 3 }),
266
+ );
267
+ act(() => {
268
+ result.current.updateField('name', 'A');
269
+ });
270
+ act(() => {
271
+ result.current.updateField('name', 'B');
272
+ });
273
+ act(() => {
274
+ result.current.updateField('name', 'C');
275
+ });
276
+ act(() => {
277
+ result.current.updateField('name', 'D');
278
+ });
279
+ // maxHistory=3, so only 3 undo steps (not 4)
280
+ act(() => {
281
+ result.current.undo();
282
+ });
283
+ expect(result.current.draft.name).toBe('C');
284
+ act(() => {
285
+ result.current.undo();
286
+ });
287
+ expect(result.current.draft.name).toBe('B');
288
+ act(() => {
289
+ result.current.undo();
290
+ });
291
+ // Should stop here — oldest entry was trimmed
292
+ expect(result.current.canUndo).toBe(false);
293
+ });
294
+ });
295
+ });
@@ -0,0 +1,276 @@
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 * as React from 'react';
10
+ import { Settings } from 'lucide-react';
11
+ import { Input } from '../ui/input';
12
+ import { Switch } from '../ui/switch';
13
+ import { Checkbox } from '../ui/checkbox';
14
+ import { Slider } from '../ui/slider';
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '../ui/select';
22
+ import { Button } from '../ui/button';
23
+ import { cn } from '../lib/utils';
24
+ import { ConfigRow } from './config-row';
25
+ import { FilterBuilder } from './filter-builder';
26
+ import { SortBuilder } from './sort-builder';
27
+ import type { ConfigField } from '../types/config-panel';
28
+
29
+ export interface ConfigFieldRendererProps {
30
+ /** Field schema */
31
+ field: ConfigField;
32
+ /** Current value */
33
+ value: any;
34
+ /** Change handler */
35
+ onChange: (value: any) => void;
36
+ /** Full draft for visibility evaluation and custom render */
37
+ draft: Record<string, any>;
38
+ /** Object definition for field-picker controls */
39
+ objectDef?: Record<string, any>;
40
+ }
41
+
42
+ /**
43
+ * Renders a single config field based on its ControlType.
44
+ *
45
+ * Supports: input, switch, select, checkbox, slider, color, icon-group, custom.
46
+ * filter/sort/field-picker are rendered as placeholders that consumers can
47
+ * override with type='custom' when full sub-editor integration is needed.
48
+ */
49
+ export function ConfigFieldRenderer({
50
+ field,
51
+ value,
52
+ onChange,
53
+ draft,
54
+ objectDef: _objectDef,
55
+ }: ConfigFieldRendererProps) {
56
+ // Visibility gate
57
+ if (field.visibleWhen && !field.visibleWhen(draft)) {
58
+ return null;
59
+ }
60
+
61
+ const effectiveDisabled = field.disabled || (field.disabledWhen ? field.disabledWhen(draft) : false);
62
+ const effectiveValue = value ?? field.defaultValue;
63
+
64
+ let content: React.ReactNode = null;
65
+
66
+ switch (field.type) {
67
+ case 'input':
68
+ content = (
69
+ <ConfigRow label={field.label}>
70
+ <Input
71
+ data-testid={`config-field-${field.key}`}
72
+ className="h-7 w-32 text-xs"
73
+ value={effectiveValue ?? ''}
74
+ placeholder={field.placeholder}
75
+ disabled={effectiveDisabled}
76
+ onChange={(e) => onChange(e.target.value)}
77
+ />
78
+ </ConfigRow>
79
+ );
80
+ break;
81
+
82
+ case 'switch':
83
+ content = (
84
+ <ConfigRow label={field.label}>
85
+ <Switch
86
+ data-testid={`config-field-${field.key}`}
87
+ checked={!!effectiveValue}
88
+ disabled={effectiveDisabled}
89
+ onCheckedChange={(checked) => onChange(checked)}
90
+ className="scale-75"
91
+ />
92
+ </ConfigRow>
93
+ );
94
+ break;
95
+
96
+ case 'checkbox':
97
+ content = (
98
+ <ConfigRow label={field.label}>
99
+ <Checkbox
100
+ data-testid={`config-field-${field.key}`}
101
+ checked={!!effectiveValue}
102
+ disabled={effectiveDisabled}
103
+ onCheckedChange={(checked) => onChange(checked)}
104
+ />
105
+ </ConfigRow>
106
+ );
107
+ break;
108
+
109
+ case 'select':
110
+ content = (
111
+ <ConfigRow label={field.label}>
112
+ <Select
113
+ value={String(effectiveValue ?? '')}
114
+ onValueChange={(val) => onChange(val)}
115
+ disabled={effectiveDisabled}
116
+ >
117
+ <SelectTrigger
118
+ data-testid={`config-field-${field.key}`}
119
+ className="h-7 w-32 text-xs"
120
+ >
121
+ <SelectValue placeholder={field.placeholder ?? 'Select…'} />
122
+ </SelectTrigger>
123
+ <SelectContent>
124
+ {(field.options ?? []).map((opt) => (
125
+ <SelectItem key={opt.value} value={opt.value}>
126
+ {opt.label}
127
+ </SelectItem>
128
+ ))}
129
+ </SelectContent>
130
+ </Select>
131
+ </ConfigRow>
132
+ );
133
+ break;
134
+
135
+ case 'slider':
136
+ content = (
137
+ <ConfigRow label={field.label}>
138
+ <div className="flex items-center gap-2 w-32">
139
+ <Slider
140
+ data-testid={`config-field-${field.key}`}
141
+ value={[Number(effectiveValue ?? field.min ?? 0)]}
142
+ min={field.min ?? 0}
143
+ max={field.max ?? 100}
144
+ step={field.step ?? 1}
145
+ disabled={effectiveDisabled}
146
+ onValueChange={(vals) => onChange(vals[0])}
147
+ aria-label={field.label}
148
+ />
149
+ <span className="text-xs text-muted-foreground w-6 text-right">
150
+ {effectiveValue ?? field.min ?? 0}
151
+ </span>
152
+ </div>
153
+ </ConfigRow>
154
+ );
155
+ break;
156
+
157
+ case 'color':
158
+ content = (
159
+ <ConfigRow label={field.label}>
160
+ <input
161
+ data-testid={`config-field-${field.key}`}
162
+ type="color"
163
+ className="h-7 w-10 rounded border cursor-pointer"
164
+ value={effectiveValue ?? '#000000'}
165
+ disabled={effectiveDisabled}
166
+ onChange={(e) => onChange(e.target.value)}
167
+ />
168
+ </ConfigRow>
169
+ );
170
+ break;
171
+
172
+ case 'icon-group':
173
+ content = (
174
+ <ConfigRow label={field.label}>
175
+ <div className="flex items-center gap-0.5" data-testid={`config-field-${field.key}`}>
176
+ {(field.options ?? []).map((opt) => (
177
+ <Button
178
+ key={opt.value}
179
+ type="button"
180
+ size="sm"
181
+ variant={effectiveValue === opt.value ? 'default' : 'ghost'}
182
+ className={cn('h-7 w-7 p-0', effectiveValue === opt.value && 'ring-1 ring-primary')}
183
+ disabled={effectiveDisabled}
184
+ onClick={() => onChange(opt.value)}
185
+ title={opt.label}
186
+ >
187
+ {opt.icon ?? <span className="text-[10px]">{opt.label.charAt(0)}</span>}
188
+ </Button>
189
+ ))}
190
+ </div>
191
+ </ConfigRow>
192
+ );
193
+ break;
194
+
195
+ case 'field-picker':
196
+ content = (
197
+ <ConfigRow
198
+ label={field.label}
199
+ value={effectiveValue ?? field.placeholder ?? 'Select field…'}
200
+ onClick={effectiveDisabled ? undefined : () => {
201
+ /* open field picker - consumers should use type='custom' for full integration */
202
+ }}
203
+ />
204
+ );
205
+ break;
206
+
207
+ case 'filter':
208
+ content = (
209
+ <div data-testid={`config-field-${field.key}`}>
210
+ <ConfigRow label={field.label} />
211
+ <FilterBuilder
212
+ fields={field.fields}
213
+ value={effectiveValue}
214
+ onChange={onChange}
215
+ className="px-1 pb-2"
216
+ />
217
+ </div>
218
+ );
219
+ break;
220
+
221
+ case 'sort':
222
+ content = (
223
+ <div data-testid={`config-field-${field.key}`}>
224
+ <ConfigRow label={field.label} />
225
+ <SortBuilder
226
+ fields={field.fields}
227
+ value={effectiveValue}
228
+ onChange={onChange}
229
+ className="px-1 pb-2"
230
+ />
231
+ </div>
232
+ );
233
+ break;
234
+
235
+ case 'custom':
236
+ if (field.render) {
237
+ content = <>{field.render(effectiveValue, onChange, draft)}</>;
238
+ }
239
+ break;
240
+
241
+ case 'summary':
242
+ content = (
243
+ <ConfigRow
244
+ label={field.label}
245
+ onClick={field.onSummaryClick}
246
+ >
247
+ <div className="flex items-center gap-1.5">
248
+ <span className="text-xs text-foreground truncate max-w-[120px]" data-testid={`config-field-${field.key}-text`}>
249
+ {field.summaryText ?? effectiveValue ?? ''}
250
+ </span>
251
+ {field.onSummaryClick && (
252
+ <Settings className="h-3.5 w-3.5 text-muted-foreground shrink-0" aria-hidden="true" data-testid={`config-field-${field.key}-gear`} />
253
+ )}
254
+ </div>
255
+ </ConfigRow>
256
+ );
257
+ break;
258
+
259
+ default:
260
+ break;
261
+ }
262
+
263
+ if (!content) return null;
264
+
265
+ // Wrap with helpText when provided
266
+ if (field.helpText) {
267
+ return (
268
+ <div>
269
+ {content}
270
+ <p className="text-[10px] text-muted-foreground mt-0.5 mb-1">{field.helpText}</p>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ return <>{content}</>;
276
+ }