@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.
- package/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +9 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24932 -23139
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/filter-builder.d.ts +1 -1
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +25 -0
- package/dist/src/renderers/action/action-button.d.ts +1 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +206 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/filter-builder.test.tsx +409 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/filter-builder.tsx +76 -25
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/debug/index.ts +10 -0
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +221 -0
- package/src/renderers/action/action-button.tsx +17 -6
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- 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
|
+
}
|