Skip to content

Commit ba62c57

Browse files
Copilothotlong
andcommitted
fix: align DashboardWidgetSchema with @objectstack/spec and fix missing React keys
- Update DashboardWidgetSchema interface and Zod schema to support both legacy component format and spec shorthand format (type/options) - Fix missing React keys for metric widgets without id or title - Remove unsafe (widget as any) casts in DashboardRenderer and DashboardGridLayout - Add tests for spec shorthand metric widgets and unique key generation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 989ce93 commit ba62c57

5 files changed

Lines changed: 115 additions & 21 deletions

File tree

packages/plugin-dashboard/src/DashboardGridLayout.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,11 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
126126
const getComponentSchema = React.useCallback((widget: DashboardWidgetSchema) => {
127127
if (widget.component) return widget.component;
128128

129-
const widgetType = (widget as any).type;
129+
const widgetType = widget.type;
130+
const options = (widget.options || {}) as Record<string, any>;
130131
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
131-
const dataItems = Array.isArray((widget as any).data) ? (widget as any).data : (widget as any).data?.items || [];
132-
const options = (widget as any).options || {};
132+
const widgetData = (widget as any).data || options.data;
133+
const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
133134
const xAxisKey = options.xField || 'name';
134135
const yField = options.yField || 'value';
135136

@@ -145,17 +146,21 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
145146
}
146147

147148
if (widgetType === 'table') {
149+
const widgetData = (widget as any).data || options.data;
148150
return {
149151
type: 'data-table',
150-
...(widget as any).options,
151-
data: (widget as any).data?.items || [],
152+
...options,
153+
data: widgetData?.items || [],
152154
searchable: false,
153155
pagination: false,
154156
className: "border-0"
155157
};
156158
}
157159

158-
return widget;
160+
return {
161+
...widget,
162+
...options
163+
};
159164
}, []);
160165

161166
return (
@@ -216,7 +221,7 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
216221
{schema.widgets?.map((widget, index) => {
217222
const widgetId = widget.id || `widget-${index}`;
218223
const componentSchema = getComponentSchema(widget);
219-
const isSelfContained = (widget as any).type === 'metric';
224+
const isSelfContained = widget.type === 'metric';
220225

221226
return (
222227
<div key={widgetId} className="h-full">

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,14 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
6161
};
6262
}, [schema.refreshInterval, onRefresh, handleRefresh]);
6363

64-
const renderWidget = (widget: DashboardWidgetSchema) => {
64+
const renderWidget = (widget: DashboardWidgetSchema, index: number) => {
6565
const getComponentSchema = () => {
6666
if (widget.component) return widget.component;
6767

6868
// Handle Shorthand Registry Mappings
69-
const widgetType = (widget as any).type;
69+
const widgetType = widget.type;
70+
const options = (widget.options || {}) as Record<string, any>;
7071
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
71-
const options = (widget as any).options || {};
7272
// Support data at widget level or nested inside options
7373
const widgetData = (widget as any).data || options.data;
7474
const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
@@ -87,7 +87,6 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
8787
}
8888

8989
if (widgetType === 'table') {
90-
const options = (widget as any).options || {};
9190
// Support data at widget level or nested inside options
9291
const widgetData = (widget as any).data || options.data;
9392
return {
@@ -102,17 +101,18 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
102101

103102
return {
104103
...widget,
105-
...((widget as any).options || {})
104+
...options
106105
};
107106
};
108107

109108
const componentSchema = getComponentSchema();
110-
const isSelfContained = (widget as any).type === 'metric';
109+
const isSelfContained = widget.type === 'metric';
110+
const widgetKey = widget.id || widget.title || `widget-${index}`;
111111

112112
if (isSelfContained) {
113113
return (
114114
<div
115-
key={widget.id || widget.title}
115+
key={widgetKey}
116116
className={cn("h-full w-full", isMobile && "w-[85vw] shrink-0 snap-center")}
117117
style={!isMobile && widget.layout ? {
118118
gridColumn: `span ${widget.layout.w}`,
@@ -126,7 +126,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
126126

127127
return (
128128
<Card
129-
key={widget.id || widget.title}
129+
key={widgetKey}
130130
className={cn(
131131
"overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
132132
"bg-card/50 backdrop-blur-sm",
@@ -176,7 +176,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
176176
className="flex overflow-x-auto snap-x snap-mandatory gap-3 pb-4 [-webkit-overflow-scrolling:touch]"
177177
style={{ scrollPaddingLeft: '0.75rem' }}
178178
>
179-
{schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
179+
{schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
180180
</div>
181181
</div>
182182
);
@@ -197,7 +197,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
197197
{...props}
198198
>
199199
{refreshButton}
200-
{schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
200+
{schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
201201
</div>
202202
);
203203
}

packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,78 @@ describe('DashboardRenderer widget data extraction', () => {
184184
expect(chartSchema).toBeDefined();
185185
expect(chartSchema.data).toEqual([]);
186186
});
187+
188+
it('should render metric widgets using spec shorthand format', () => {
189+
const schema = {
190+
type: 'dashboard' as const,
191+
name: 'test',
192+
title: 'Test',
193+
widgets: [
194+
{
195+
type: 'metric',
196+
layout: { x: 0, y: 0, w: 1, h: 1 },
197+
options: {
198+
label: 'Total Revenue',
199+
value: '$652,000',
200+
trend: { value: 12.5, direction: 'up', label: 'vs last month' },
201+
icon: 'DollarSign',
202+
},
203+
},
204+
{
205+
type: 'metric',
206+
layout: { x: 1, y: 0, w: 1, h: 1 },
207+
options: {
208+
label: 'Active Deals',
209+
value: '5',
210+
trend: { value: 2.1, direction: 'down', label: 'vs last month' },
211+
icon: 'Briefcase',
212+
},
213+
},
214+
],
215+
} as any;
216+
217+
const { container } = render(<DashboardRenderer schema={schema} />);
218+
219+
// MetricWidget is registered in the ComponentRegistry, so it should render
220+
// the label and value from the merged options
221+
expect(container.textContent).toContain('Total Revenue');
222+
expect(container.textContent).toContain('$652,000');
223+
expect(container.textContent).toContain('Active Deals');
224+
expect(container.textContent).toContain('5');
225+
});
226+
227+
it('should assign unique keys to widgets without id or title', () => {
228+
const schema = {
229+
type: 'dashboard' as const,
230+
name: 'test',
231+
title: 'Test',
232+
widgets: [
233+
{
234+
type: 'metric',
235+
layout: { x: 0, y: 0, w: 1, h: 1 },
236+
options: { label: 'Metric A', value: '100' },
237+
},
238+
{
239+
type: 'metric',
240+
layout: { x: 1, y: 0, w: 1, h: 1 },
241+
options: { label: 'Metric B', value: '200' },
242+
},
243+
{
244+
type: 'metric',
245+
layout: { x: 2, y: 0, w: 1, h: 1 },
246+
options: { label: 'Metric C', value: '300' },
247+
},
248+
],
249+
} as any;
250+
251+
const { container } = render(<DashboardRenderer schema={schema} />);
252+
253+
// All three metrics should render without React key warnings
254+
expect(container.textContent).toContain('Metric A');
255+
expect(container.textContent).toContain('Metric B');
256+
expect(container.textContent).toContain('Metric C');
257+
expect(container.textContent).toContain('100');
258+
expect(container.textContent).toContain('200');
259+
expect(container.textContent).toContain('300');
260+
});
187261
});

packages/types/src/complex.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,12 +503,21 @@ export interface DashboardWidgetLayout {
503503

504504
/**
505505
* Dashboard Widget
506+
*
507+
* Supports two formats:
508+
* 1. **Component format** (legacy): `{ id, component: { type, ... }, layout }`
509+
* 2. **Shorthand format** (@objectstack/spec): `{ type: 'metric'|'bar'|…, options: {…}, layout }`
506510
*/
507511
export interface DashboardWidgetSchema {
508-
id: string;
512+
id?: string;
509513
title?: string;
510-
component: SchemaNode;
514+
/** Component schema (legacy format) */
515+
component?: SchemaNode;
511516
layout?: DashboardWidgetLayout;
517+
/** Widget visualization type (spec shorthand format) */
518+
type?: string;
519+
/** Widget-specific configuration (spec shorthand format) */
520+
options?: unknown;
512521
}
513522

514523
/**

packages/types/src/zod/complex.zod.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,18 @@ export const DashboardWidgetLayoutSchema = z.object({
227227

228228
/**
229229
* Dashboard Widget Schema
230+
*
231+
* Supports two formats:
232+
* 1. Component format (legacy): `{ id, component: { type, ... }, layout }`
233+
* 2. Shorthand format (@objectstack/spec): `{ type: 'metric'|'bar'|…, options: {…}, layout }`
230234
*/
231235
export const DashboardWidgetSchema = z.object({
232-
id: z.string().describe('Widget ID'),
236+
id: z.string().optional().describe('Widget ID'),
233237
title: z.string().optional().describe('Widget Title'),
234-
component: SchemaNodeSchema.describe('Widget Component'),
238+
component: SchemaNodeSchema.optional().describe('Widget Component (legacy format)'),
235239
layout: DashboardWidgetLayoutSchema.optional().describe('Widget Layout'),
240+
type: z.string().optional().describe('Widget visualization type (spec shorthand)'),
241+
options: z.unknown().optional().describe('Widget specific configuration (spec shorthand)'),
236242
});
237243

238244
/**

0 commit comments

Comments
 (0)