Skip to content

Commit fdd42ec

Browse files
authored
Merge pull request #1152 from objectstack-ai/copilot/fix-dashboard-static-data-display
2 parents f06cdc8 + fd8f2f6 commit fdd42ec

File tree

13 files changed

+727
-87
lines changed

13 files changed

+727
-87
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727

2828
### Fixed
2929

30+
- **Dashboard widgets now surface API errors instead of showing hardcoded data** (`@object-ui/plugin-dashboard`, `@object-ui/plugin-charts`):
31+
- **ObjectChart**: Added error state tracking. When `dataSource.aggregate()` or `dataSource.find()` fails, the chart now shows a prominent error message with a red alert icon instead of silently swallowing errors and rendering an empty chart.
32+
- **MetricWidget / MetricCard**: Added `loading` and `error` props. When provided, the widget shows a loading spinner or a destructive-colored error message instead of the metric value, making API failures immediately visible.
33+
- **ObjectMetricWidget** (new component): Data-bound metric widget that fetches live values from the server via `dataSource.aggregate()` or `dataSource.find()`. Shows explicit loading/error states. Falls back to static `fallbackValue` only when no `dataSource` is available (demo mode).
34+
- **DashboardRenderer**: Metric widgets with `widget.object` binding are now routed to `ObjectMetricWidget` (`object-metric` type) for async data loading, instead of always rendering static hardcoded values. Static-only metric widgets (no `object` binding) continue to work as before.
35+
- **CRM dashboard example**: Documented that `options.value` fields are demo/fallback values that only display when no dataSource is connected.
36+
- 13 new tests covering error states, loading states, fallback behavior, and routing logic.
37+
3038
- **Plugin designer test infrastructure** (`@object-ui/plugin-designer`): Created missing `vitest.setup.ts` with ResizeObserver polyfill and jest-dom matchers. Added `@object-ui/i18n` alias to vite config. These fixes resolved 9 pre-existing test suite failures, bringing total passing tests from 45 to 246.
3139

3240
- **Chinese language pack (zh.ts) untranslated key** (`@object-ui/i18n`): Fixed `console.objectView.toolbarEnabledCount` which was still in English (`'{{count}} of {{total}} enabled'`) — now properly translated to `'已启用 {{count}}/{{total}} 项'`. Also fixed the same untranslated key in all other 8 non-English locales (ja, ko, de, fr, es, pt, ru, ar).

examples/crm/src/dashboards/crm.dashboard.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ export const CrmDashboard = {
44
description: 'Revenue metrics, pipeline analytics, and deal insights',
55
widgets: [
66
// --- KPI Row ---
7+
// NOTE: `options.value` is a fallback displayed only when no dataSource is
8+
// available (e.g. demo/storybook mode). In production, the DashboardRenderer
9+
// routes these to ObjectMetricWidget which fetches live data from the server.
10+
// If the server request fails, an explicit error state is shown.
711
{
812
id: 'total_revenue',
913
title: { key: 'crm.dashboard.widgets.totalRevenue', defaultValue: 'Total Revenue' },

packages/plugin-charts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@object-ui/core": "workspace:*",
3636
"@object-ui/react": "workspace:*",
3737
"@object-ui/types": "workspace:*",
38+
"lucide-react": "^0.577.0",
3839
"recharts": "^3.8.1"
3940
},
4041
"peerDependencies": {

packages/plugin-charts/src/ObjectChart.tsx

Lines changed: 68 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11

2-
import React, { useState, useEffect, useContext } from 'react';
2+
import React, { useState, useEffect, useContext, useCallback } from 'react';
33
import { useDataScope, SchemaRendererContext } from '@object-ui/react';
44
import { ChartRenderer } from './ChartRenderer';
55
import { ComponentRegistry, extractRecords } from '@object-ui/core';
6+
import { AlertCircle } from 'lucide-react';
67

78
/**
89
* Client-side aggregation for fetched records.
@@ -60,56 +61,64 @@ export const ObjectChart = (props: any) => {
6061

6162
const [fetchedData, setFetchedData] = useState<any[]>([]);
6263
const [loading, setLoading] = useState(false);
64+
const [error, setError] = useState<string | null>(null);
65+
66+
const fetchData = useCallback(async (ds: any, mounted: { current: boolean }) => {
67+
if (!ds || !schema.objectName) return;
68+
if (mounted.current) {
69+
setLoading(true);
70+
setError(null);
71+
}
72+
try {
73+
let data: any[];
74+
75+
// Prefer server-side aggregation when aggregate config is provided
76+
// and dataSource supports the aggregate() method.
77+
if (schema.aggregate && typeof ds.aggregate === 'function') {
78+
const results = await ds.aggregate(schema.objectName, {
79+
field: schema.aggregate.field,
80+
function: schema.aggregate.function,
81+
groupBy: schema.aggregate.groupBy,
82+
filter: schema.filter,
83+
});
84+
data = Array.isArray(results) ? results : [];
85+
} else if (typeof ds.find === 'function') {
86+
// Fallback: fetch all records and aggregate client-side
87+
const results = await ds.find(schema.objectName, {
88+
$filter: schema.filter
89+
});
90+
91+
data = extractRecords(results);
92+
93+
// Apply client-side aggregation when aggregate config is provided
94+
if (schema.aggregate && data.length > 0) {
95+
data = aggregateRecords(data, schema.aggregate);
96+
}
97+
} else {
98+
return;
99+
}
100+
101+
if (mounted.current) {
102+
setFetchedData(data);
103+
}
104+
} catch (e) {
105+
console.error('[ObjectChart] Fetch error:', e);
106+
if (mounted.current) {
107+
setError(e instanceof Error ? e.message : 'Failed to load chart data');
108+
}
109+
} finally {
110+
if (mounted.current) setLoading(false);
111+
}
112+
}, [schema.objectName, schema.aggregate, schema.filter]);
63113

64114
useEffect(() => {
65-
let isMounted = true;
66-
const fetchData = async () => {
67-
if (!dataSource || !schema.objectName) return;
68-
if (isMounted) setLoading(true);
69-
try {
70-
let data: any[];
71-
72-
// Prefer server-side aggregation when aggregate config is provided
73-
// and dataSource supports the aggregate() method.
74-
if (schema.aggregate && typeof dataSource.aggregate === 'function') {
75-
const results = await dataSource.aggregate(schema.objectName, {
76-
field: schema.aggregate.field,
77-
function: schema.aggregate.function,
78-
groupBy: schema.aggregate.groupBy,
79-
filter: schema.filter,
80-
});
81-
data = Array.isArray(results) ? results : [];
82-
} else if (typeof dataSource.find === 'function') {
83-
// Fallback: fetch all records and aggregate client-side
84-
const results = await dataSource.find(schema.objectName, {
85-
$filter: schema.filter
86-
});
87-
88-
data = extractRecords(results);
89-
90-
// Apply client-side aggregation when aggregate config is provided
91-
if (schema.aggregate && data.length > 0) {
92-
data = aggregateRecords(data, schema.aggregate);
93-
}
94-
} else {
95-
return;
96-
}
97-
98-
if (isMounted) {
99-
setFetchedData(data);
100-
}
101-
} catch (e) {
102-
console.error('[ObjectChart] Fetch error:', e);
103-
} finally {
104-
if (isMounted) setLoading(false);
105-
}
106-
};
115+
const mounted = { current: true };
107116

108117
if (schema.objectName && !boundData && !schema.data) {
109-
fetchData();
118+
fetchData(dataSource, mounted);
110119
}
111-
return () => { isMounted = false; };
112-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate]);
120+
return () => { mounted.current = false; };
121+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate, fetchData]);
113122

114123
const rawData = boundData || schema.data || fetchedData;
115124
const finalData = Array.isArray(rawData) ? rawData : [];
@@ -121,11 +130,22 @@ export const ObjectChart = (props: any) => {
121130
};
122131

123132
if (loading && finalData.length === 0) {
124-
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>Loading chart data…</div>;
133+
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')} data-testid="chart-loading">Loading chart data…</div>;
134+
}
135+
136+
// Error state — show the error prominently so issues are not hidden
137+
if (error) {
138+
return (
139+
<div className={"flex flex-col items-center justify-center gap-2 p-4 " + (schema.className || '')} data-testid="chart-error" role="alert">
140+
<AlertCircle className="h-6 w-6 text-destructive opacity-60" />
141+
<p className="text-xs text-destructive font-medium">Failed to load chart data</p>
142+
<p className="text-xs text-muted-foreground max-w-xs text-center">{error}</p>
143+
</div>
144+
);
125145
}
126146

127147
if (!dataSource && schema.objectName && finalData.length === 0) {
128-
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>No data source available for "{schema.objectName}"</div>;
148+
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')} data-testid="chart-no-datasource">No data source available for &ldquo;{schema.objectName}&rdquo;</div>;
129149
}
130150

131151
return <ChartRenderer {...props} schema={finalSchema} />;

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,40 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
124124
// Handle Shorthand Registry Mappings
125125
const widgetType = widget.type;
126126
const options = (widget.options || {}) as Record<string, any>;
127+
128+
// Metric widgets with object binding — delegate to ObjectMetricWidget
129+
// for async data loading with proper error/loading states.
130+
// Static metric options (label, value, trend, icon) are passed as
131+
// fallback values that render only when no dataSource is available.
132+
if (widgetType === 'metric' && widget.object) {
133+
const widgetData = options.data;
134+
const aggregate = isObjectProvider(widgetData) && widgetData.aggregate
135+
? {
136+
field: widget.valueField || widgetData.aggregate.field,
137+
function: widget.aggregate || widgetData.aggregate.function,
138+
// Prefer explicit categoryField or aggregate.groupBy; otherwise, default to a single bucket.
139+
groupBy: widget.categoryField ?? widgetData.aggregate.groupBy ?? '_all',
140+
}
141+
: widget.aggregate ? {
142+
field: widget.valueField || 'value',
143+
function: widget.aggregate,
144+
// Default to a single group unless the user explicitly configures a categoryField.
145+
groupBy: widget.categoryField || '_all',
146+
} : undefined;
147+
148+
return {
149+
type: 'object-metric',
150+
objectName: widget.object || (isObjectProvider(widgetData) ? widgetData.object : undefined),
151+
aggregate,
152+
filter: (isObjectProvider(widgetData) ? widgetData.filter : undefined) || widget.filter,
153+
label: options.label || resolveLabel(widget.title) || '',
154+
fallbackValue: options.value,
155+
trend: options.trend,
156+
icon: options.icon,
157+
description: options.description,
158+
};
159+
}
160+
127161
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter') {
128162
// Support data at widget level or nested inside options
129163
const widgetData = (widget as any).data || options.data;

packages/plugin-dashboard/src/MetricCard.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import React from 'react';
1010
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
1111
import { cn } from '@object-ui/components';
12-
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
12+
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
1313
import * as LucideIcons from 'lucide-react';
1414

1515
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
@@ -27,6 +27,10 @@ export interface MetricCardProps {
2727
trendValue?: string;
2828
description?: string | { key?: string; defaultValue?: string };
2929
className?: string;
30+
/** When true, the card is in a loading state (fetching data from server). */
31+
loading?: boolean;
32+
/** Error message from a failed data fetch. When set, the card shows an error state. */
33+
error?: string | null;
3034
}
3135

3236
/**
@@ -41,6 +45,8 @@ export const MetricCard: React.FC<MetricCardProps> = ({
4145
trendValue,
4246
description,
4347
className,
48+
loading,
49+
error,
4450
...props
4551
}) => {
4652
// Resolve icon from lucide-react
@@ -57,24 +63,38 @@ export const MetricCard: React.FC<MetricCardProps> = ({
5763
)}
5864
</CardHeader>
5965
<CardContent>
60-
<div className="text-2xl font-bold">{value}</div>
61-
{(trend || trendValue || description) && (
62-
<p className="text-xs text-muted-foreground flex items-center mt-1">
63-
{trend && trendValue && (
64-
<span className={cn(
65-
"flex items-center mr-2",
66-
trend === 'up' && "text-green-500",
67-
trend === 'down' && "text-red-500",
68-
trend === 'neutral' && "text-yellow-500"
69-
)}>
70-
{trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
71-
{trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
72-
{trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
73-
{trendValue}
74-
</span>
66+
{loading ? (
67+
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-card-loading">
68+
<Loader2 className="h-4 w-4 animate-spin" />
69+
<span className="text-sm">Loading…</span>
70+
</div>
71+
) : error ? (
72+
<div className="flex items-center gap-2" data-testid="metric-card-error" role="alert">
73+
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
74+
<span className="text-xs text-destructive truncate">{error}</span>
75+
</div>
76+
) : (
77+
<>
78+
<div className="text-2xl font-bold">{value}</div>
79+
{(trend || trendValue || description) && (
80+
<p className="text-xs text-muted-foreground flex items-center mt-1">
81+
{trend && trendValue && (
82+
<span className={cn(
83+
"flex items-center mr-2",
84+
trend === 'up' && "text-green-500",
85+
trend === 'down' && "text-red-500",
86+
trend === 'neutral' && "text-yellow-500"
87+
)}>
88+
{trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
89+
{trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
90+
{trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
91+
{trendValue}
92+
</span>
93+
)}
94+
{resolveLabel(description)}
95+
</p>
7596
)}
76-
{resolveLabel(description)}
77-
</p>
97+
</>
7898
)}
7999
</CardContent>
80100
</Card>

packages/plugin-dashboard/src/MetricWidget.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useMemo } from 'react';
22
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
33
import { cn } from '@object-ui/components';
4-
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
4+
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
55
import * as LucideIcons from 'lucide-react';
66

77
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
@@ -22,6 +22,10 @@ export interface MetricWidgetProps {
2222
icon?: React.ReactNode | string;
2323
className?: string;
2424
description?: string | { key?: string; defaultValue?: string };
25+
/** When true, the widget is in a loading state (fetching data from server). */
26+
loading?: boolean;
27+
/** Error message from a failed data fetch. When set, the widget shows an error state. */
28+
error?: string | null;
2529
}
2630

2731
export const MetricWidget = ({
@@ -31,6 +35,8 @@ export const MetricWidget = ({
3135
icon,
3236
className,
3337
description,
38+
loading,
39+
error,
3440
...props
3541
}: MetricWidgetProps) => {
3642
// Resolve icon if it's a string
@@ -51,24 +57,38 @@ export const MetricWidget = ({
5157
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground shrink-0">{resolvedIcon}</div>}
5258
</CardHeader>
5359
<CardContent>
54-
<div className="text-2xl font-bold truncate">{value}</div>
55-
{(trend || description) && (
56-
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
57-
{trend && (
58-
<span className={cn(
59-
"flex items-center mr-2 shrink-0",
60-
trend.direction === 'up' && "text-green-500",
61-
trend.direction === 'down' && "text-red-500",
62-
trend.direction === 'neutral' && "text-yellow-500"
63-
)}>
64-
{trend.direction === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
65-
{trend.direction === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
66-
{trend.direction === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
67-
{trend.value}%
68-
</span>
60+
{loading ? (
61+
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-loading">
62+
<Loader2 className="h-4 w-4 animate-spin" />
63+
<span className="text-sm">Loading…</span>
64+
</div>
65+
) : error ? (
66+
<div className="flex items-center gap-2" data-testid="metric-error" role="alert">
67+
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
68+
<span className="text-xs text-destructive truncate">{error}</span>
69+
</div>
70+
) : (
71+
<>
72+
<div className="text-2xl font-bold truncate">{value}</div>
73+
{(trend || description) && (
74+
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
75+
{trend && (
76+
<span className={cn(
77+
"flex items-center mr-2 shrink-0",
78+
trend.direction === 'up' && "text-green-500",
79+
trend.direction === 'down' && "text-red-500",
80+
trend.direction === 'neutral' && "text-yellow-500"
81+
)}>
82+
{trend.direction === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
83+
{trend.direction === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
84+
{trend.direction === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
85+
{trend.value}%
86+
</span>
87+
)}
88+
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
89+
</p>
6990
)}
70-
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
71-
</p>
91+
</>
7292
)}
7393
</CardContent>
7494
</Card>

0 commit comments

Comments
 (0)