Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Dashboard widgets now surface API errors instead of showing hardcoded data** (`@object-ui/plugin-dashboard`, `@object-ui/plugin-charts`):
- **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.
- **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.
- **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).
- **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.
- **CRM dashboard example**: Documented that `options.value` fields are demo/fallback values that only display when no dataSource is connected.
- 13 new tests covering error states, loading states, fallback behavior, and routing logic.

- **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.

- **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).
Expand Down
4 changes: 4 additions & 0 deletions examples/crm/src/dashboards/crm.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const CrmDashboard = {
description: 'Revenue metrics, pipeline analytics, and deal insights',
widgets: [
// --- KPI Row ---
// NOTE: `options.value` is a fallback displayed only when no dataSource is
// available (e.g. demo/storybook mode). In production, the DashboardRenderer
// routes these to ObjectMetricWidget which fetches live data from the server.
// If the server request fails, an explicit error state is shown.
{
id: 'total_revenue',
title: { key: 'crm.dashboard.widgets.totalRevenue', defaultValue: 'Total Revenue' },
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@object-ui/core": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"lucide-react": "^0.577.0",
"recharts": "^3.8.1"
},
"peerDependencies": {
Expand Down
116 changes: 68 additions & 48 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@

import React, { useState, useEffect, useContext } from 'react';
import React, { useState, useEffect, useContext, useCallback } from 'react';
import { useDataScope, SchemaRendererContext } from '@object-ui/react';
import { ChartRenderer } from './ChartRenderer';
import { ComponentRegistry, extractRecords } from '@object-ui/core';
import { AlertCircle } from 'lucide-react';

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

const [fetchedData, setFetchedData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchData = useCallback(async (ds: any, mounted: { current: boolean }) => {
if (!ds || !schema.objectName) return;
if (mounted.current) {
setLoading(true);
setError(null);
}
try {
let data: any[];

// Prefer server-side aggregation when aggregate config is provided
// and dataSource supports the aggregate() method.
if (schema.aggregate && typeof ds.aggregate === 'function') {
const results = await ds.aggregate(schema.objectName, {
field: schema.aggregate.field,
function: schema.aggregate.function,
groupBy: schema.aggregate.groupBy,
filter: schema.filter,
});
data = Array.isArray(results) ? results : [];
} else if (typeof ds.find === 'function') {
// Fallback: fetch all records and aggregate client-side
const results = await ds.find(schema.objectName, {
$filter: schema.filter
});

data = extractRecords(results);

// Apply client-side aggregation when aggregate config is provided
if (schema.aggregate && data.length > 0) {
data = aggregateRecords(data, schema.aggregate);
}
} else {
return;
}

if (mounted.current) {
setFetchedData(data);
}
} catch (e) {
console.error('[ObjectChart] Fetch error:', e);
if (mounted.current) {
setError(e instanceof Error ? e.message : 'Failed to load chart data');
}
} finally {
if (mounted.current) setLoading(false);
}
}, [schema.objectName, schema.aggregate, schema.filter]);

useEffect(() => {
let isMounted = true;
const fetchData = async () => {
if (!dataSource || !schema.objectName) return;
if (isMounted) setLoading(true);
try {
let data: any[];

// Prefer server-side aggregation when aggregate config is provided
// and dataSource supports the aggregate() method.
if (schema.aggregate && typeof dataSource.aggregate === 'function') {
const results = await dataSource.aggregate(schema.objectName, {
field: schema.aggregate.field,
function: schema.aggregate.function,
groupBy: schema.aggregate.groupBy,
filter: schema.filter,
});
data = Array.isArray(results) ? results : [];
} else if (typeof dataSource.find === 'function') {
// Fallback: fetch all records and aggregate client-side
const results = await dataSource.find(schema.objectName, {
$filter: schema.filter
});

data = extractRecords(results);

// Apply client-side aggregation when aggregate config is provided
if (schema.aggregate && data.length > 0) {
data = aggregateRecords(data, schema.aggregate);
}
} else {
return;
}

if (isMounted) {
setFetchedData(data);
}
} catch (e) {
console.error('[ObjectChart] Fetch error:', e);
} finally {
if (isMounted) setLoading(false);
}
};
const mounted = { current: true };

if (schema.objectName && !boundData && !schema.data) {
fetchData();
fetchData(dataSource, mounted);
}
return () => { isMounted = false; };
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate]);
return () => { mounted.current = false; };
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, schema.aggregate, fetchData]);

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

if (loading && finalData.length === 0) {
return <div className={"flex items-center justify-center text-muted-foreground text-sm p-4 " + (schema.className || '')}>Loading chart data…</div>;
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>;
}

// Error state — show the error prominently so issues are not hidden
if (error) {
return (
<div className={"flex flex-col items-center justify-center gap-2 p-4 " + (schema.className || '')} data-testid="chart-error" role="alert">
<AlertCircle className="h-6 w-6 text-destructive opacity-60" />
<p className="text-xs text-destructive font-medium">Failed to load chart data</p>
<p className="text-xs text-muted-foreground max-w-xs text-center">{error}</p>
</div>
);
}

if (!dataSource && schema.objectName && finalData.length === 0) {
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>;
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>;
}

return <ChartRenderer {...props} schema={finalSchema} />;
Expand Down
34 changes: 34 additions & 0 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,40 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
// Handle Shorthand Registry Mappings
const widgetType = widget.type;
const options = (widget.options || {}) as Record<string, any>;

// Metric widgets with object binding — delegate to ObjectMetricWidget
// for async data loading with proper error/loading states.
// Static metric options (label, value, trend, icon) are passed as
// fallback values that render only when no dataSource is available.
if (widgetType === 'metric' && widget.object) {
const widgetData = options.data;
const aggregate = isObjectProvider(widgetData) && widgetData.aggregate
? {
field: widget.valueField || widgetData.aggregate.field,
function: widget.aggregate || widgetData.aggregate.function,
// Prefer explicit categoryField or aggregate.groupBy; otherwise, default to a single bucket.
groupBy: widget.categoryField ?? widgetData.aggregate.groupBy ?? '_all',
}
: widget.aggregate ? {
field: widget.valueField || 'value',
function: widget.aggregate,
// Default to a single group unless the user explicitly configures a categoryField.
groupBy: widget.categoryField || '_all',
} : undefined;

return {
type: 'object-metric',
objectName: widget.object || (isObjectProvider(widgetData) ? widgetData.object : undefined),
aggregate,
filter: (isObjectProvider(widgetData) ? widgetData.filter : undefined) || widget.filter,
label: options.label || resolveLabel(widget.title) || '',
fallbackValue: options.value,
trend: options.trend,
icon: options.icon,
description: options.description,
};
}

if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter') {
// Support data at widget level or nested inside options
const widgetData = (widget as any).data || options.data;
Expand Down
56 changes: 38 additions & 18 deletions packages/plugin-dashboard/src/MetricCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
import { cn } from '@object-ui/components';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
import * as LucideIcons from 'lucide-react';

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

/**
Expand All @@ -41,6 +45,8 @@ export const MetricCard: React.FC<MetricCardProps> = ({
trendValue,
description,
className,
loading,
error,
...props
}) => {
// Resolve icon from lucide-react
Expand All @@ -57,24 +63,38 @@ export const MetricCard: React.FC<MetricCardProps> = ({
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{(trend || trendValue || description) && (
<p className="text-xs text-muted-foreground flex items-center mt-1">
{trend && trendValue && (
<span className={cn(
"flex items-center mr-2",
trend === 'up' && "text-green-500",
trend === 'down' && "text-red-500",
trend === 'neutral' && "text-yellow-500"
)}>
{trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
{trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
{trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
{trendValue}
</span>
{loading ? (
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-card-loading">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading…</span>
</div>
) : error ? (
<div className="flex items-center gap-2" data-testid="metric-card-error" role="alert">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-xs text-destructive truncate">{error}</span>
</div>
) : (
<>
<div className="text-2xl font-bold">{value}</div>
{(trend || trendValue || description) && (
<p className="text-xs text-muted-foreground flex items-center mt-1">
{trend && trendValue && (
<span className={cn(
"flex items-center mr-2",
trend === 'up' && "text-green-500",
trend === 'down' && "text-red-500",
trend === 'neutral' && "text-yellow-500"
)}>
{trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
{trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
{trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
{trendValue}
</span>
)}
{resolveLabel(description)}
</p>
)}
{resolveLabel(description)}
</p>
</>
)}
</CardContent>
</Card>
Expand Down
56 changes: 38 additions & 18 deletions packages/plugin-dashboard/src/MetricWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
import { cn } from '@object-ui/components';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
import * as LucideIcons from 'lucide-react';

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

export const MetricWidget = ({
Expand All @@ -31,6 +35,8 @@ export const MetricWidget = ({
icon,
className,
description,
loading,
error,
...props
}: MetricWidgetProps) => {
// Resolve icon if it's a string
Expand All @@ -51,24 +57,38 @@ export const MetricWidget = ({
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground shrink-0">{resolvedIcon}</div>}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold truncate">{value}</div>
{(trend || description) && (
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
{trend && (
<span className={cn(
"flex items-center mr-2 shrink-0",
trend.direction === 'up' && "text-green-500",
trend.direction === 'down' && "text-red-500",
trend.direction === 'neutral' && "text-yellow-500"
)}>
{trend.direction === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
{trend.direction === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
{trend.direction === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
{trend.value}%
</span>
{loading ? (
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-loading">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading…</span>
</div>
) : error ? (
<div className="flex items-center gap-2" data-testid="metric-error" role="alert">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-xs text-destructive truncate">{error}</span>
</div>
) : (
<>
<div className="text-2xl font-bold truncate">{value}</div>
{(trend || description) && (
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
{trend && (
<span className={cn(
"flex items-center mr-2 shrink-0",
trend.direction === 'up' && "text-green-500",
trend.direction === 'down' && "text-red-500",
trend.direction === 'neutral' && "text-yellow-500"
)}>
{trend.direction === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
{trend.direction === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
{trend.direction === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
{trend.value}%
</span>
)}
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
</p>
)}
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
</p>
</>
)}
</CardContent>
</Card>
Expand Down
Loading