Skip to content

Commit 2d144bd

Browse files
authored
Merge pull request #732 from objectstack-ai/copilot/build-schema-driven-panel-framework
2 parents 9a0486d + b08bb1b commit 2d144bd

24 files changed

+2045
-3
lines changed

ROADMAP.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,16 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
311311
312312
**Phase 0 — Component Infrastructure:**
313313
- [x] Extract `ConfigRow` / `SectionHeader` from `ViewConfigPanel` into `@object-ui/components` as reusable primitives
314+
- [x] Implement `useConfigDraft` generic hook for draft state management (dirty tracking, save/discard)
315+
- [x] Define `ConfigPanelSchema` / `ConfigSection` / `ConfigField` types for schema-driven panel generation
316+
- [x] Implement `ConfigFieldRenderer` supporting input/switch/select/checkbox/slider/color/icon-group/field-picker/filter/sort/custom
317+
- [x] Implement `ConfigPanelRenderer` — schema-driven panel with header, breadcrumb, collapsible sections, sticky footer
318+
- [x] Add `configPanel` i18n keys to all 10 locale files
314319

315320
**Phase 1 — Dashboard-Level Config Panel:**
316-
- [ ] Develop `DashboardConfigPanel` supporting data source, layout (columns/gap), filtering, appearance, user filters & actions
321+
- [x] Develop `DashboardConfigPanel` supporting layout (columns/gap/rowHeight), data (refreshInterval), appearance (title/description/theme)
322+
- [x] Add Storybook stories for `ConfigPanelRenderer` and `DashboardConfigPanel`
323+
- [x] Add Vitest tests (65 tests: useConfigDraft 10, ConfigFieldRenderer 22, ConfigPanelRenderer 21, DashboardConfigPanel 12)
317324

318325
**Phase 2 — Widget-Level Configuration:**
319326
- [ ] Support click-to-select widget → sidebar switches to widget property editor (title, type, data binding, layout)
@@ -324,7 +331,6 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
324331

325332
**Phase 4 — Composition & Storybook:**
326333
- [ ] Build `DashboardWithConfig` composite component (dashboard + config sidebar)
327-
- [ ] Add Storybook stories for `DashboardConfigPanel` and `DashboardWithConfig`
328334

329335
**Phase 5 — Type Definitions & Validation:**
330336
- [x] Add `DashboardConfig` types to `@object-ui/types`
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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 { render, screen, fireEvent } from '@testing-library/react';
11+
import { ConfigFieldRenderer } from '../custom/config-field-renderer';
12+
import type { ConfigField } from '../types/config-panel';
13+
14+
const defaultDraft = { name: 'Test', enabled: true, theme: 'dark', count: 5 };
15+
16+
describe('ConfigFieldRenderer', () => {
17+
describe('input type', () => {
18+
const field: ConfigField = { key: 'name', label: 'Name', type: 'input', placeholder: 'Enter name' };
19+
20+
it('should render input with label', () => {
21+
render(<ConfigFieldRenderer field={field} value="Test" onChange={vi.fn()} draft={defaultDraft} />);
22+
expect(screen.getByText('Name')).toBeDefined();
23+
expect(screen.getByTestId('config-field-name')).toBeDefined();
24+
});
25+
26+
it('should display current value', () => {
27+
render(<ConfigFieldRenderer field={field} value="Hello" onChange={vi.fn()} draft={defaultDraft} />);
28+
expect((screen.getByTestId('config-field-name') as HTMLInputElement).value).toBe('Hello');
29+
});
30+
31+
it('should call onChange on input', () => {
32+
const onChange = vi.fn();
33+
render(<ConfigFieldRenderer field={field} value="" onChange={onChange} draft={defaultDraft} />);
34+
fireEvent.change(screen.getByTestId('config-field-name'), { target: { value: 'New' } });
35+
expect(onChange).toHaveBeenCalledWith('New');
36+
});
37+
38+
it('should use defaultValue when value is undefined', () => {
39+
const fieldWithDefault: ConfigField = { key: 'name', label: 'Name', type: 'input', defaultValue: 'Default' };
40+
render(<ConfigFieldRenderer field={fieldWithDefault} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
41+
expect((screen.getByTestId('config-field-name') as HTMLInputElement).value).toBe('Default');
42+
});
43+
});
44+
45+
describe('switch type', () => {
46+
const field: ConfigField = { key: 'enabled', label: 'Enabled', type: 'switch' };
47+
48+
it('should render switch with label', () => {
49+
render(<ConfigFieldRenderer field={field} value={true} onChange={vi.fn()} draft={defaultDraft} />);
50+
expect(screen.getByText('Enabled')).toBeDefined();
51+
expect(screen.getByTestId('config-field-enabled')).toBeDefined();
52+
});
53+
54+
it('should toggle on click', () => {
55+
const onChange = vi.fn();
56+
render(<ConfigFieldRenderer field={field} value={false} onChange={onChange} draft={defaultDraft} />);
57+
fireEvent.click(screen.getByTestId('config-field-enabled'));
58+
expect(onChange).toHaveBeenCalledWith(true);
59+
});
60+
});
61+
62+
describe('checkbox type', () => {
63+
const field: ConfigField = { key: 'checked', label: 'Active', type: 'checkbox' };
64+
65+
it('should render checkbox with label', () => {
66+
render(<ConfigFieldRenderer field={field} value={false} onChange={vi.fn()} draft={defaultDraft} />);
67+
expect(screen.getByText('Active')).toBeDefined();
68+
expect(screen.getByTestId('config-field-checked')).toBeDefined();
69+
});
70+
71+
it('should toggle on click', () => {
72+
const onChange = vi.fn();
73+
render(<ConfigFieldRenderer field={field} value={false} onChange={onChange} draft={defaultDraft} />);
74+
fireEvent.click(screen.getByTestId('config-field-checked'));
75+
expect(onChange).toHaveBeenCalledWith(true);
76+
});
77+
});
78+
79+
describe('select type', () => {
80+
const field: ConfigField = {
81+
key: 'theme',
82+
label: 'Theme',
83+
type: 'select',
84+
options: [
85+
{ value: 'light', label: 'Light' },
86+
{ value: 'dark', label: 'Dark' },
87+
{ value: 'auto', label: 'Auto' },
88+
],
89+
};
90+
91+
it('should render select trigger with label', () => {
92+
render(<ConfigFieldRenderer field={field} value="dark" onChange={vi.fn()} draft={defaultDraft} />);
93+
expect(screen.getByText('Theme')).toBeDefined();
94+
expect(screen.getByTestId('config-field-theme')).toBeDefined();
95+
});
96+
});
97+
98+
describe('slider type', () => {
99+
const field: ConfigField = {
100+
key: 'count',
101+
label: 'Count',
102+
type: 'slider',
103+
min: 1,
104+
max: 10,
105+
step: 1,
106+
};
107+
108+
it('should render slider with label and value display', () => {
109+
render(<ConfigFieldRenderer field={field} value={5} onChange={vi.fn()} draft={defaultDraft} />);
110+
expect(screen.getByText('Count')).toBeDefined();
111+
expect(screen.getByText('5')).toBeDefined();
112+
});
113+
});
114+
115+
describe('color type', () => {
116+
const field: ConfigField = { key: 'bgColor', label: 'Background', type: 'color' };
117+
118+
it('should render color picker', () => {
119+
render(<ConfigFieldRenderer field={field} value="#ff0000" onChange={vi.fn()} draft={defaultDraft} />);
120+
expect(screen.getByText('Background')).toBeDefined();
121+
const input = screen.getByTestId('config-field-bgColor') as HTMLInputElement;
122+
expect(input.type).toBe('color');
123+
expect(input.value).toBe('#ff0000');
124+
});
125+
126+
it('should call onChange on color change', () => {
127+
const onChange = vi.fn();
128+
render(<ConfigFieldRenderer field={field} value="#ff0000" onChange={onChange} draft={defaultDraft} />);
129+
fireEvent.change(screen.getByTestId('config-field-bgColor'), { target: { value: '#00ff00' } });
130+
expect(onChange).toHaveBeenCalledWith('#00ff00');
131+
});
132+
});
133+
134+
describe('icon-group type', () => {
135+
const field: ConfigField = {
136+
key: 'size',
137+
label: 'Size',
138+
type: 'icon-group',
139+
options: [
140+
{ value: 'sm', label: 'Small' },
141+
{ value: 'md', label: 'Medium' },
142+
{ value: 'lg', label: 'Large' },
143+
],
144+
};
145+
146+
it('should render icon group buttons', () => {
147+
render(<ConfigFieldRenderer field={field} value="md" onChange={vi.fn()} draft={defaultDraft} />);
148+
expect(screen.getByText('Size')).toBeDefined();
149+
expect(screen.getByTitle('Small')).toBeDefined();
150+
expect(screen.getByTitle('Medium')).toBeDefined();
151+
expect(screen.getByTitle('Large')).toBeDefined();
152+
});
153+
154+
it('should call onChange when button clicked', () => {
155+
const onChange = vi.fn();
156+
render(<ConfigFieldRenderer field={field} value="sm" onChange={onChange} draft={defaultDraft} />);
157+
fireEvent.click(screen.getByTitle('Large'));
158+
expect(onChange).toHaveBeenCalledWith('lg');
159+
});
160+
});
161+
162+
describe('custom type', () => {
163+
it('should render custom content via render prop', () => {
164+
const field: ConfigField = {
165+
key: 'custom',
166+
label: 'Custom',
167+
type: 'custom',
168+
render: (value, onChange) => (
169+
<div data-testid="custom-render">
170+
<span>Custom: {value}</span>
171+
<button onClick={() => onChange('updated')}>Update</button>
172+
</div>
173+
),
174+
};
175+
const onChange = vi.fn();
176+
render(<ConfigFieldRenderer field={field} value="initial" onChange={onChange} draft={defaultDraft} />);
177+
expect(screen.getByTestId('custom-render')).toBeDefined();
178+
expect(screen.getByText('Custom: initial')).toBeDefined();
179+
fireEvent.click(screen.getByText('Update'));
180+
expect(onChange).toHaveBeenCalledWith('updated');
181+
});
182+
183+
it('should return null for custom type without render', () => {
184+
const field: ConfigField = { key: 'empty', label: 'Empty', type: 'custom' };
185+
const { container } = render(<ConfigFieldRenderer field={field} value={null} onChange={vi.fn()} draft={defaultDraft} />);
186+
expect(container.innerHTML).toBe('');
187+
});
188+
});
189+
190+
describe('visibility', () => {
191+
it('should hide field when visibleWhen returns false', () => {
192+
const field: ConfigField = {
193+
key: 'hidden',
194+
label: 'Hidden',
195+
type: 'input',
196+
visibleWhen: () => false,
197+
};
198+
const { container } = render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
199+
expect(container.innerHTML).toBe('');
200+
});
201+
202+
it('should show field when visibleWhen returns true', () => {
203+
const field: ConfigField = {
204+
key: 'visible',
205+
label: 'Visible',
206+
type: 'input',
207+
visibleWhen: (draft) => draft.enabled === true,
208+
};
209+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
210+
expect(screen.getByText('Visible')).toBeDefined();
211+
});
212+
213+
it('should evaluate visibleWhen against draft', () => {
214+
const field: ConfigField = {
215+
key: 'conditional',
216+
label: 'Conditional',
217+
type: 'input',
218+
visibleWhen: (draft) => draft.theme === 'dark',
219+
};
220+
const { container: hidden } = render(
221+
<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={{ ...defaultDraft, theme: 'light' }} />,
222+
);
223+
expect(hidden.innerHTML).toBe('');
224+
225+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={{ ...defaultDraft, theme: 'dark' }} />);
226+
expect(screen.getByText('Conditional')).toBeDefined();
227+
});
228+
});
229+
230+
describe('field-picker type', () => {
231+
it('should render as clickable config row', () => {
232+
const field: ConfigField = { key: 'field', label: 'Select Field', type: 'field-picker', placeholder: 'Choose...' };
233+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
234+
expect(screen.getByText('Select Field')).toBeDefined();
235+
expect(screen.getByText('Choose...')).toBeDefined();
236+
});
237+
});
238+
239+
describe('filter/sort types', () => {
240+
it('should render filter placeholder', () => {
241+
const field: ConfigField = { key: 'filter', label: 'Filters', type: 'filter' };
242+
render(<ConfigFieldRenderer field={field} value={null} onChange={vi.fn()} draft={defaultDraft} />);
243+
expect(screen.getByText('Filters')).toBeDefined();
244+
});
245+
246+
it('should render sort placeholder', () => {
247+
const field: ConfigField = { key: 'sort', label: 'Sorting', type: 'sort' };
248+
render(<ConfigFieldRenderer field={field} value={null} onChange={vi.fn()} draft={defaultDraft} />);
249+
expect(screen.getByText('Sorting')).toBeDefined();
250+
});
251+
});
252+
});

0 commit comments

Comments
 (0)