Skip to content

Commit 32bb995

Browse files
authored
Merge pull request #881 from objectstack-ai/copilot/fix-metric-render-error
2 parents 1afdb33 + 7a49c25 commit 32bb995

5 files changed

Lines changed: 93 additions & 9 deletions

File tree

ROADMAP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,14 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
10431043

10441044
**Tests:** Added 2 tests in `data-table-airtable-ux.test.tsx` verifying filler rows are skipped when pagination is off and still rendered when pagination is on. All 59 related tests pass.
10451045

1046+
### Metric Widget I18nLabel Render Crash (February 2026)
1047+
1048+
**Root Cause:** `MetricWidget` and `MetricCard` rendered I18nLabel objects (`{key, defaultValue}`) directly as React children. When CRM dashboard metadata used I18nLabel objects for `trend.label` (e.g. `{ key: 'crm.dashboard.trendLabel', defaultValue: 'vs last month' }`), React threw error #31 ("Objects are not valid as a React child").
1049+
1050+
**Fix:** Added `resolveLabel()` helper to `MetricWidget.tsx` and `MetricCard.tsx` that converts I18nLabel objects to plain strings before rendering. Updated prop types to accept both string and I18nLabel objects for `label`, `description`, and `trend.label`.
1051+
1052+
**Tests:** Added 3 new tests: 1 in `DashboardRenderer.widgetData.test.tsx` verifying metric widgets with I18nLabel trend labels render correctly, and 2 in `MetricCard.test.tsx` verifying I18nLabel resolution for title and description. All 159 dashboard tests pass.
1053+
10461054
---
10471055

10481056
## ⚠️ Risk Management

packages/plugin-dashboard/src/MetricCard.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@ import { cn } from '@object-ui/components';
1212
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
1313
import * as LucideIcons from 'lucide-react';
1414

15+
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
16+
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
17+
if (label === undefined || label === null) return undefined;
18+
if (typeof label === 'string') return label;
19+
return label.defaultValue || label.key;
20+
}
21+
1522
export interface MetricCardProps {
16-
title?: string;
23+
title?: string | { key?: string; defaultValue?: string };
1724
value: string | number;
1825
icon?: string;
1926
trend?: 'up' | 'down' | 'neutral';
2027
trendValue?: string;
21-
description?: string;
28+
description?: string | { key?: string; defaultValue?: string };
2229
className?: string;
2330
}
2431

@@ -43,7 +50,7 @@ export const MetricCard: React.FC<MetricCardProps> = ({
4350
<Card className={cn("h-full", className)} {...props}>
4451
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
4552
<CardTitle className="text-sm font-medium">
46-
{title}
53+
{resolveLabel(title)}
4754
</CardTitle>
4855
{IconComponent && (
4956
<IconComponent className="h-4 w-4 text-muted-foreground" />
@@ -66,7 +73,7 @@ export const MetricCard: React.FC<MetricCardProps> = ({
6673
{trendValue}
6774
</span>
6875
)}
69-
{description}
76+
{resolveLabel(description)}
7077
</p>
7178
)}
7279
</CardContent>

packages/plugin-dashboard/src/MetricWidget.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@ import { cn } from '@object-ui/components';
44
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
55
import * as LucideIcons from 'lucide-react';
66

7+
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
8+
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
9+
if (label === undefined || label === null) return undefined;
10+
if (typeof label === 'string') return label;
11+
return label.defaultValue || label.key;
12+
}
13+
714
export interface MetricWidgetProps {
8-
label: string;
15+
label: string | { key?: string; defaultValue?: string };
916
value: string | number;
1017
trend?: {
1118
value: number;
12-
label?: string;
19+
label?: string | { key?: string; defaultValue?: string };
1320
direction?: 'up' | 'down' | 'neutral';
1421
};
1522
icon?: React.ReactNode | string;
1623
className?: string;
17-
description?: string;
24+
description?: string | { key?: string; defaultValue?: string };
1825
}
1926

2027
export const MetricWidget = ({
@@ -39,7 +46,7 @@ export const MetricWidget = ({
3946
<Card className={cn("h-full", className)} {...props}>
4047
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
4148
<CardTitle className="text-sm font-medium">
42-
{label}
49+
{resolveLabel(label)}
4350
</CardTitle>
4451
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground">{resolvedIcon}</div>}
4552
</CardHeader>
@@ -60,7 +67,7 @@ export const MetricWidget = ({
6067
{trend.value}%
6168
</span>
6269
)}
63-
{description || trend?.label}
70+
{resolveLabel(description) || resolveLabel(trend?.label)}
6471
</p>
6572
)}
6673
</CardContent>

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,45 @@ describe('DashboardRenderer widget data extraction', () => {
224224
expect(container.textContent).toContain('5');
225225
});
226226

227+
it('should render metric widgets with I18nLabel objects without crashing', () => {
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: {
237+
label: 'Total Revenue',
238+
value: '$652,000',
239+
trend: { value: 12.5, direction: 'up', label: { key: 'crm.dashboard.trendLabel', defaultValue: 'vs last month' } },
240+
icon: 'DollarSign',
241+
},
242+
},
243+
{
244+
type: 'metric',
245+
layout: { x: 1, y: 0, w: 1, h: 1 },
246+
options: {
247+
label: 'Active Deals',
248+
value: '5',
249+
trend: { value: 2.1, direction: 'down', label: { key: 'crm.dashboard.trendLabel', defaultValue: 'vs last month' } },
250+
icon: 'Briefcase',
251+
},
252+
},
253+
],
254+
} as any;
255+
256+
const { container } = render(<DashboardRenderer schema={schema} />);
257+
258+
// Should resolve I18nLabel objects to their defaultValue strings
259+
expect(container.textContent).toContain('Total Revenue');
260+
expect(container.textContent).toContain('$652,000');
261+
expect(container.textContent).toContain('vs last month');
262+
expect(container.textContent).toContain('Active Deals');
263+
expect(container.textContent).toContain('5');
264+
});
265+
227266
it('should assign unique keys to widgets without id or title', () => {
228267
const schema = {
229268
type: 'dashboard' as const,

packages/plugin-dashboard/src/__tests__/MetricCard.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,27 @@ describe('MetricCard', () => {
5656
expect(screen.getByText('Count')).toBeInTheDocument();
5757
expect(screen.getByText('1234')).toBeInTheDocument();
5858
});
59+
60+
it('should resolve I18nLabel objects for title', () => {
61+
render(
62+
<MetricCard
63+
title={{ key: 'crm.dashboard.widgets.totalRevenue', defaultValue: 'Total Revenue' }}
64+
value="$45,231"
65+
/>
66+
);
67+
68+
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
69+
});
70+
71+
it('should resolve I18nLabel objects for description', () => {
72+
render(
73+
<MetricCard
74+
title="Revenue"
75+
value="$45,231"
76+
description={{ key: 'crm.dashboard.trendLabel', defaultValue: 'vs last month' }}
77+
/>
78+
);
79+
80+
expect(screen.getByText('vs last month')).toBeInTheDocument();
81+
});
5982
});

0 commit comments

Comments
 (0)