Skip to content

Commit 686b220

Browse files
authored
Merge pull request #733 from objectstack-ai/copilot/build-schema-driven-framework
2 parents 2d144bd + aa02b09 commit 686b220

File tree

10 files changed

+1029
-16
lines changed

10 files changed

+1029
-16
lines changed

ROADMAP.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,21 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
323323
- [x] Add Vitest tests (65 tests: useConfigDraft 10, ConfigFieldRenderer 22, ConfigPanelRenderer 21, DashboardConfigPanel 12)
324324

325325
**Phase 2 — Widget-Level Configuration:**
326-
- [ ] Support click-to-select widget → sidebar switches to widget property editor (title, type, data binding, layout)
326+
- [x] Support click-to-select widget → sidebar switches to widget property editor (title, type, data binding, layout)
327+
- [x] Implement `WidgetConfigPanel` with schema-driven fields: general (title, description, type), data binding (object, categoryField, valueField, aggregate), layout (width, height), appearance (colorVariant, actionUrl)
328+
- [x] Add Vitest tests (14 tests for WidgetConfigPanel)
327329

328330
**Phase 3 — Sub-Editor Integration:**
329-
- [ ] Integrate `FilterBuilder` for dashboard global filters
331+
- [x] Integrate `FilterBuilder` for dashboard global filters (ConfigFieldRenderer `filter` type now renders inline FilterBuilder)
332+
- [x] Integrate `SortBuilder` for sort configuration (ConfigFieldRenderer `sort` type now renders inline SortBuilder)
333+
- [x] Add `fields` prop to `ConfigField` type for filter/sort field definitions
330334
- [ ] Dropdown filter selector and action button sub-panel visual editing
331335

332336
**Phase 4 — Composition & Storybook:**
333-
- [ ] Build `DashboardWithConfig` composite component (dashboard + config sidebar)
337+
- [x] Build `DashboardWithConfig` composite component (dashboard + config sidebar)
338+
- [x] Support widget selection → WidgetConfigPanel switch with back navigation
339+
- [x] Add Storybook stories for `WidgetConfigPanel`, `DashboardWithConfig`, and `DashboardWithConfigClosed`
340+
- [x] Add Vitest tests (9 tests for DashboardWithConfig)
334341

335342
**Phase 5 — Type Definitions & Validation:**
336343
- [x] Add `DashboardConfig` types to `@object-ui/types`

packages/components/src/__tests__/config-field-renderer.test.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,16 +237,38 @@ describe('ConfigFieldRenderer', () => {
237237
});
238238

239239
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} />);
240+
it('should render filter with FilterBuilder', () => {
241+
const field: ConfigField = {
242+
key: 'filter',
243+
label: 'Filters',
244+
type: 'filter',
245+
fields: [
246+
{ value: 'name', label: 'Name' },
247+
{ value: 'status', label: 'Status' },
248+
],
249+
};
250+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
243251
expect(screen.getByText('Filters')).toBeDefined();
252+
expect(screen.getByTestId('config-field-filter')).toBeDefined();
253+
// FilterBuilder renders 'Where' label and 'Add filter' button
254+
expect(screen.getByText('Add filter')).toBeDefined();
244255
});
245256

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} />);
257+
it('should render sort with SortBuilder', () => {
258+
const field: ConfigField = {
259+
key: 'sort',
260+
label: 'Sorting',
261+
type: 'sort',
262+
fields: [
263+
{ value: 'name', label: 'Name' },
264+
{ value: 'date', label: 'Date' },
265+
],
266+
};
267+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
249268
expect(screen.getByText('Sorting')).toBeDefined();
269+
expect(screen.getByTestId('config-field-sort')).toBeDefined();
270+
// SortBuilder renders 'Add sort' button
271+
expect(screen.getByText('Add sort')).toBeDefined();
250272
});
251273
});
252274
});

packages/components/src/custom/config-field-renderer.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
import { Button } from '../ui/button';
2222
import { cn } from '../lib/utils';
2323
import { ConfigRow } from './config-row';
24+
import { FilterBuilder } from './filter-builder';
25+
import { SortBuilder } from './sort-builder';
2426
import type { ConfigField } from '../types/config-panel';
2527

2628
export interface ConfigFieldRendererProps {
@@ -191,14 +193,29 @@ export function ConfigFieldRenderer({
191193
);
192194

193195
case 'filter':
196+
return (
197+
<div data-testid={`config-field-${field.key}`}>
198+
<ConfigRow label={field.label} />
199+
<FilterBuilder
200+
fields={field.fields}
201+
value={effectiveValue}
202+
onChange={onChange}
203+
className="px-1 pb-2"
204+
/>
205+
</div>
206+
);
207+
194208
case 'sort':
195-
// Complex sub-editors — consumers should use type='custom' to embed
196-
// FilterBuilder / SortBuilder with full field binding.
197209
return (
198-
<ConfigRow
199-
label={field.label}
200-
value={field.placeholder ?? `Configure ${field.type}…`}
201-
/>
210+
<div data-testid={`config-field-${field.key}`}>
211+
<ConfigRow label={field.label} />
212+
<SortBuilder
213+
fields={field.fields}
214+
value={effectiveValue}
215+
onChange={onChange}
216+
className="px-1 pb-2"
217+
/>
218+
</div>
202219
);
203220

204221
case 'custom':

packages/components/src/types/config-panel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export interface ConfigField {
5959
step?: number;
6060
/** Whether the field is disabled */
6161
disabled?: boolean;
62+
/** Field definitions for filter/sort sub-editors */
63+
fields?: Array<{ value: string; label: string; type?: string; options?: Array<{ value: string; label: string }> }>;
6264
}
6365

6466
/** A group of related config fields */
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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 React, { useState } from 'react';
10+
import type { Meta, StoryObj } from '@storybook/react';
11+
import { WidgetConfigPanel } from './WidgetConfigPanel';
12+
import { DashboardWithConfig } from './DashboardWithConfig';
13+
import type { DashboardSchema } from '@object-ui/types';
14+
15+
// ─── WidgetConfigPanel Stories ──────────────────────────────────────────────
16+
17+
const widgetMeta = {
18+
title: 'Plugins/DashboardConfigPanel',
19+
parameters: {
20+
layout: 'padded',
21+
},
22+
tags: ['autodocs'],
23+
} satisfies Meta;
24+
25+
export default widgetMeta;
26+
type Story = StoryObj<typeof widgetMeta>;
27+
28+
// --- WidgetConfigPanel ---
29+
30+
const widgetConfig = {
31+
title: 'Revenue Chart',
32+
description: 'Monthly revenue by region',
33+
type: 'bar',
34+
object: 'orders',
35+
categoryField: 'region',
36+
valueField: 'amount',
37+
aggregate: 'sum',
38+
colorVariant: 'blue',
39+
actionUrl: '',
40+
layoutW: 2,
41+
layoutH: 1,
42+
};
43+
44+
function WidgetConfigStory() {
45+
const [config, setConfig] = useState(widgetConfig);
46+
return (
47+
<div style={{ position: 'relative', height: 600, width: 320, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
48+
<WidgetConfigPanel
49+
open={true}
50+
onClose={() => alert('Close clicked')}
51+
config={config}
52+
onSave={(newConfig) => {
53+
setConfig(newConfig as typeof widgetConfig);
54+
alert('Saved: ' + JSON.stringify(newConfig, null, 2));
55+
}}
56+
/>
57+
</div>
58+
);
59+
}
60+
61+
export const WidgetConfig: Story = {
62+
render: () => <WidgetConfigStory />,
63+
};
64+
65+
// --- DashboardWithConfig ---
66+
67+
const dashboardSchema: DashboardSchema = {
68+
type: 'dashboard',
69+
title: 'Sales Dashboard',
70+
columns: 3,
71+
gap: 4,
72+
widgets: [
73+
{
74+
id: 'mc-1',
75+
component: {
76+
type: 'metric-card',
77+
title: 'Total Revenue',
78+
value: '$128,430',
79+
icon: 'DollarSign',
80+
trend: 'up',
81+
trendValue: '+14.2%',
82+
},
83+
layout: { x: 0, y: 0, w: 1, h: 1 },
84+
},
85+
{
86+
id: 'mc-2',
87+
component: {
88+
type: 'metric-card',
89+
title: 'Active Users',
90+
value: '3,842',
91+
icon: 'Users',
92+
trend: 'up',
93+
trendValue: '+8.1%',
94+
},
95+
layout: { x: 1, y: 0, w: 1, h: 1 },
96+
},
97+
{
98+
id: 'mc-3',
99+
component: {
100+
type: 'metric-card',
101+
title: 'Churn Rate',
102+
value: '1.8%',
103+
icon: 'TrendingDown',
104+
trend: 'down',
105+
trendValue: '-0.3%',
106+
},
107+
layout: { x: 2, y: 0, w: 1, h: 1 },
108+
},
109+
],
110+
header: {
111+
showTitle: true,
112+
showDescription: true,
113+
},
114+
};
115+
116+
const dashboardConfig = {
117+
columns: 3,
118+
gap: 4,
119+
rowHeight: '120',
120+
refreshInterval: '0',
121+
title: 'Sales Dashboard',
122+
showDescription: true,
123+
theme: 'auto',
124+
};
125+
126+
function DashboardWithConfigStory() {
127+
const [config, setConfig] = useState(dashboardConfig);
128+
return (
129+
<div style={{ height: 600, width: '100%', border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
130+
<DashboardWithConfig
131+
schema={dashboardSchema}
132+
config={config}
133+
onConfigSave={(newConfig) => {
134+
setConfig(newConfig as typeof dashboardConfig);
135+
}}
136+
defaultConfigOpen={true}
137+
/>
138+
</div>
139+
);
140+
}
141+
142+
export const DashboardWithConfigPanel: Story = {
143+
render: () => <DashboardWithConfigStory />,
144+
};
145+
146+
function DashboardWithConfigClosedStory() {
147+
const [config, setConfig] = useState(dashboardConfig);
148+
return (
149+
<div style={{ height: 600, width: '100%', border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
150+
<DashboardWithConfig
151+
schema={dashboardSchema}
152+
config={config}
153+
onConfigSave={(newConfig) => {
154+
setConfig(newConfig as typeof dashboardConfig);
155+
}}
156+
defaultConfigOpen={false}
157+
/>
158+
</div>
159+
);
160+
}
161+
162+
export const DashboardWithConfigClosed: Story = {
163+
render: () => <DashboardWithConfigClosedStory />,
164+
};

0 commit comments

Comments
 (0)