diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b3b453c5..4a29f276a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
diff --git a/examples/crm/src/dashboards/crm.dashboard.ts b/examples/crm/src/dashboards/crm.dashboard.ts
index 19d32534b..eb6d1c152 100644
--- a/examples/crm/src/dashboards/crm.dashboard.ts
+++ b/examples/crm/src/dashboards/crm.dashboard.ts
@@ -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' },
diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json
index 6d13aeb40..2c9175f89 100644
--- a/packages/plugin-charts/package.json
+++ b/packages/plugin-charts/package.json
@@ -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": {
diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx
index ba3b55619..b389a2310 100644
--- a/packages/plugin-charts/src/ObjectChart.tsx
+++ b/packages/plugin-charts/src/ObjectChart.tsx
@@ -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.
@@ -60,56 +61,64 @@ export const ObjectChart = (props: any) => {
const [fetchedData, setFetchedData] = useState([]);
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 : [];
@@ -121,11 +130,22 @@ export const ObjectChart = (props: any) => {
};
if (loading && finalData.length === 0) {
- return Loading chart data…
;
+ return Loading chart data…
;
+ }
+
+ // Error state — show the error prominently so issues are not hidden
+ if (error) {
+ return (
+
+
+
Failed to load chart data
+
{error}
+
+ );
}
if (!dataSource && schema.objectName && finalData.length === 0) {
- return No data source available for "{schema.objectName}"
;
+ return No data source available for “{schema.objectName}”
;
}
return ;
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx
index 01d1b66e3..491fd2b77 100644
--- a/packages/plugin-dashboard/src/DashboardRenderer.tsx
+++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx
@@ -124,6 +124,40 @@ export const DashboardRenderer = forwardRef;
+
+ // 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;
diff --git a/packages/plugin-dashboard/src/MetricCard.tsx b/packages/plugin-dashboard/src/MetricCard.tsx
index e1a831832..4d04defcf 100644
--- a/packages/plugin-dashboard/src/MetricCard.tsx
+++ b/packages/plugin-dashboard/src/MetricCard.tsx
@@ -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. */
@@ -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;
}
/**
@@ -41,6 +45,8 @@ export const MetricCard: React.FC = ({
trendValue,
description,
className,
+ loading,
+ error,
...props
}) => {
// Resolve icon from lucide-react
@@ -57,24 +63,38 @@ export const MetricCard: React.FC = ({
)}
- {value}
- {(trend || trendValue || description) && (
-
- {trend && trendValue && (
-
- {trend === 'up' && }
- {trend === 'down' && }
- {trend === 'neutral' && }
- {trendValue}
-
+ {loading ? (
+
+
+ Loading…
+
+ ) : error ? (
+
+ ) : (
+ <>
+ {value}
+ {(trend || trendValue || description) && (
+
+ {trend && trendValue && (
+
+ {trend === 'up' && }
+ {trend === 'down' && }
+ {trend === 'neutral' && }
+ {trendValue}
+
+ )}
+ {resolveLabel(description)}
+
)}
- {resolveLabel(description)}
-
+ >
)}
diff --git a/packages/plugin-dashboard/src/MetricWidget.tsx b/packages/plugin-dashboard/src/MetricWidget.tsx
index e5d304cc4..b19eb7515 100644
--- a/packages/plugin-dashboard/src/MetricWidget.tsx
+++ b/packages/plugin-dashboard/src/MetricWidget.tsx
@@ -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. */
@@ -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 = ({
@@ -31,6 +35,8 @@ export const MetricWidget = ({
icon,
className,
description,
+ loading,
+ error,
...props
}: MetricWidgetProps) => {
// Resolve icon if it's a string
@@ -51,24 +57,38 @@ export const MetricWidget = ({
{resolvedIcon && {resolvedIcon}
}
- {value}
- {(trend || description) && (
-
- {trend && (
-
- {trend.direction === 'up' && }
- {trend.direction === 'down' && }
- {trend.direction === 'neutral' && }
- {trend.value}%
-
+ {loading ? (
+
+
+ Loading…
+
+ ) : error ? (
+
+ ) : (
+ <>
+ {value}
+ {(trend || description) && (
+
+ {trend && (
+
+ {trend.direction === 'up' && }
+ {trend.direction === 'down' && }
+ {trend.direction === 'neutral' && }
+ {trend.value}%
+
+ )}
+ {resolveLabel(description) || resolveLabel(trend?.label)}
+
)}
- {resolveLabel(description) || resolveLabel(trend?.label)}
-
+ >
)}
diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx
new file mode 100644
index 000000000..5f2a8b1f6
--- /dev/null
+++ b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx
@@ -0,0 +1,159 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect, useContext, useCallback } from 'react';
+import { SchemaRendererContext } from '@object-ui/react';
+import { MetricWidget } from './MetricWidget';
+
+/**
+ * ObjectMetricWidget — Data-bound metric widget.
+ *
+ * When a metric widget has an `object` binding and a `dataSource` is available,
+ * this component attempts to fetch the metric value from the server using
+ * aggregation. If the fetch fails, it shows an error state instead of
+ * silently displaying stale/hardcoded data.
+ *
+ * Lifecycle states:
+ * - **Loading** → spinner placeholder
+ * - **Error** → error message (API failure is surfaced, not hidden)
+ * - **Data** → actual metric value from server
+ * - **Fallback** → when no dataSource is available, renders the static
+ * `options.value` as provided in the widget config (demo/fallback mode)
+ */
+export interface ObjectMetricWidgetProps {
+ /** The object/resource name to query */
+ objectName: string;
+ /** Aggregation config (field, function, groupBy) */
+ aggregate?: { field: string; function: string; groupBy?: string };
+ /** Filter conditions */
+ filter?: any;
+ /** Static label for the metric */
+ label: string | { key?: string; defaultValue?: string };
+ /** Fallback static value (used when no dataSource or in demo mode) */
+ fallbackValue?: string | number;
+ /** Trend info */
+ trend?: {
+ value: number;
+ label?: string | { key?: string; defaultValue?: string };
+ direction?: 'up' | 'down' | 'neutral';
+ };
+ /** Icon name or ReactNode */
+ icon?: React.ReactNode | string;
+ /** Additional CSS class */
+ className?: string;
+ /** Description */
+ description?: string | { key?: string; defaultValue?: string };
+ /** External data source (overrides context) */
+ dataSource?: any;
+}
+
+export const ObjectMetricWidget: React.FC = ({
+ objectName,
+ aggregate,
+ filter,
+ label,
+ fallbackValue,
+ trend,
+ icon,
+ className,
+ description,
+ dataSource: propDataSource,
+}) => {
+ const context = useContext(SchemaRendererContext);
+ const dataSource = propDataSource || context?.dataSource;
+
+ const [fetchedValue, setFetchedValue] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchMetric = useCallback(async (ds: any, mounted: { current: boolean }) => {
+ if (!ds || !objectName) return;
+ if (mounted.current) {
+ setLoading(true);
+ setError(null);
+ }
+
+ try {
+ let value: string | number;
+
+ if (aggregate && typeof ds.aggregate === 'function') {
+ // Server-side aggregation
+ const results = await ds.aggregate(objectName, {
+ field: aggregate.field,
+ function: aggregate.function,
+ groupBy: aggregate.groupBy || '_all',
+ filter,
+ });
+ const data = Array.isArray(results) ? results : [];
+
+ if (data.length === 0) {
+ value = 0;
+ } else if (aggregate.function === 'count') {
+ // Sum all count results
+ value = data.reduce((sum: number, r: any) => sum + (Number(r[aggregate.field]) || Number(r.count) || 0), 0);
+ } else {
+ // Take the first result's value
+ value = data[0][aggregate.field] ?? 0;
+ }
+ } else if (typeof ds.find === 'function') {
+ // Fallback: count records
+ const results = await ds.find(objectName, { $filter: filter });
+ const records = Array.isArray(results) ? results : results?.data || results?.records || [];
+ value = records.length;
+ } else {
+ return;
+ }
+
+ if (mounted.current) {
+ setFetchedValue(value);
+ }
+ } catch (e) {
+ console.error('[ObjectMetricWidget] Fetch error:', e);
+ if (mounted.current) {
+ setError(e instanceof Error ? e.message : 'Failed to load metric');
+ }
+ } finally {
+ if (mounted.current) setLoading(false);
+ }
+ }, [objectName, aggregate, filter]);
+
+ useEffect(() => {
+ const mounted = { current: true };
+
+ if (dataSource && objectName) {
+ fetchMetric(dataSource, mounted);
+ } else {
+ // Reset state when dataSource becomes unavailable so we fall back
+ // to the static fallbackValue instead of showing stale server data.
+ setFetchedValue(null);
+ setError(null);
+ }
+
+ return () => { mounted.current = false; };
+ }, [dataSource, objectName, fetchMetric]);
+
+ // Determine the display value:
+ // - If we fetched a value from the server, use it
+ // - If there's no data source, use the fallback (demo/static value)
+ const displayValue = fetchedValue !== null
+ ? fetchedValue
+ : (!dataSource ? (fallbackValue ?? '—') : '—');
+
+ return (
+
+ );
+};
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx
index 196891819..5e5d81a9d 100644
--- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx
@@ -6,8 +6,9 @@
* LICENSE file in the root directory of this source tree.
*/
-import { describe, it, expect } from 'vitest';
-import { render } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { SchemaRendererProvider } from '@object-ui/react';
import { DashboardRenderer } from '../DashboardRenderer';
/**
@@ -1280,4 +1281,131 @@ describe('DashboardRenderer widget data extraction', () => {
expect((card as HTMLElement).style.gridColumn).toBe('span 3');
}
});
+
+ // --- Metric widget with object binding → object-metric ---
+ // When widget.type === 'metric' AND widget.object is set, DashboardRenderer
+ // routes to the registered 'object-metric' component (ObjectMetricWidget).
+ // Without a dataSource in context, it renders the static fallbackValue.
+
+ it('should route metric widgets with object binding to object-metric (renders fallback without dataSource)', () => {
+ const schema = {
+ type: 'dashboard' as const,
+ name: 'test',
+ title: 'Test',
+ widgets: [
+ {
+ type: 'metric',
+ object: 'opportunity',
+ layout: { x: 0, y: 0, w: 1, h: 1 },
+ options: {
+ label: 'Total Revenue',
+ value: '$652,000',
+ icon: 'DollarSign',
+ },
+ },
+ ],
+ } as any;
+
+ const { container } = render();
+
+ // ObjectMetricWidget renders fallbackValue when no dataSource is present
+ expect(container.textContent).toContain('Total Revenue');
+ expect(container.textContent).toContain('$652,000');
+ });
+
+ it('should keep static metric widgets as-is when no object binding', () => {
+ const schema = {
+ type: 'dashboard' as const,
+ name: 'test',
+ title: 'Test',
+ widgets: [
+ {
+ type: 'metric',
+ layout: { x: 0, y: 0, w: 1, h: 1 },
+ options: {
+ label: 'Static Metric',
+ value: '42',
+ },
+ },
+ ],
+ } as any;
+
+ const { container } = render();
+
+ // Static metrics without object binding should render the value directly
+ expect(container.textContent).toContain('Static Metric');
+ expect(container.textContent).toContain('42');
+ });
+
+ it('should route metric with data.provider object to object-metric (renders fallback without dataSource)', () => {
+ const schema = {
+ type: 'dashboard' as const,
+ name: 'test',
+ title: 'Test',
+ widgets: [
+ {
+ type: 'metric',
+ object: 'opportunity',
+ layout: { x: 0, y: 0, w: 1, h: 1 },
+ options: {
+ label: 'Revenue Sum',
+ value: '$0',
+ data: {
+ provider: 'object',
+ object: 'opportunity',
+ aggregate: { field: 'amount', function: 'sum', groupBy: '_all' },
+ },
+ },
+ },
+ ],
+ } as any;
+
+ const { container } = render();
+
+ // ObjectMetricWidget renders fallbackValue when no dataSource is present
+ expect(container.textContent).toContain('Revenue Sum');
+ expect(container.textContent).toContain('$0');
+ });
+
+ it('should show error state when object-metric dataSource fails', async () => {
+ const dataSource = {
+ aggregate: vi.fn().mockRejectedValue(new Error('Cube name is required')),
+ find: vi.fn(),
+ };
+
+ const schema = {
+ type: 'dashboard' as const,
+ name: 'test',
+ title: 'Test',
+ widgets: [
+ {
+ type: 'metric',
+ object: 'opportunity',
+ aggregate: 'sum',
+ valueField: 'amount',
+ layout: { x: 0, y: 0, w: 1, h: 1 },
+ options: {
+ label: 'Revenue',
+ value: '$999',
+ },
+ },
+ ],
+ } as any;
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ // Should display the error from the failing aggregate, not the fallback value
+ await waitFor(() => {
+ const errorEl = container.querySelector('[data-testid="metric-error"]');
+ expect(errorEl).toBeTruthy();
+ });
+
+ expect(container.textContent).toContain('Revenue');
+ expect(container.textContent).toContain('Cube name is required');
+ expect(container.textContent).not.toContain('$999');
+ });
});
diff --git a/packages/plugin-dashboard/src/__tests__/MetricCard.test.tsx b/packages/plugin-dashboard/src/__tests__/MetricCard.test.tsx
index 3c2e9794d..3cae8d489 100644
--- a/packages/plugin-dashboard/src/__tests__/MetricCard.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/MetricCard.test.tsx
@@ -79,4 +79,29 @@ describe('MetricCard', () => {
expect(screen.getByText('vs last month')).toBeInTheDocument();
});
+
+ it('should show loading state when loading prop is true', () => {
+ const { container } = render(
+
+ );
+
+ const loadingEl = container.querySelector('[data-testid="metric-card-loading"]');
+ expect(loadingEl).toBeTruthy();
+ // Value should not be rendered during loading
+ expect(container.textContent).not.toContain('$45,231');
+ });
+
+ it('should show error state when error prop is set', () => {
+ const { container } = render(
+
+ );
+
+ const errorEl = container.querySelector('[data-testid="metric-card-error"]');
+ expect(errorEl).toBeTruthy();
+ expect(screen.getByText('Connection refused')).toBeInTheDocument();
+ // Value should not be rendered during error
+ expect(container.textContent).not.toContain('$45,231');
+ // Title should still be visible
+ expect(screen.getByText('Revenue')).toBeInTheDocument();
+ });
});
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.test.tsx
new file mode 100644
index 000000000..b0b37774d
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import '@testing-library/jest-dom';
+import { ObjectMetricWidget } from '../ObjectMetricWidget';
+import { SchemaRendererProvider } from '@object-ui/react';
+
+// Suppress console.error from expected fetch errors
+const originalConsoleError = console.error;
+beforeEach(() => {
+ console.error = vi.fn();
+});
+afterEach(() => {
+ console.error = originalConsoleError;
+});
+
+describe('ObjectMetricWidget', () => {
+ const baseProps = {
+ objectName: 'opportunity',
+ label: 'Total Revenue',
+ fallbackValue: '$652,000',
+ icon: 'DollarSign',
+ };
+
+ it('should show fallback value when no dataSource is available', () => {
+ render();
+
+ expect(screen.getByText('Total Revenue')).toBeInTheDocument();
+ expect(screen.getByText('$652,000')).toBeInTheDocument();
+ });
+
+ it('should show loading state while fetching', async () => {
+ const dataSource = {
+ find: vi.fn(() => new Promise(() => {})), // Never resolves
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const loadingEl = container.querySelector('[data-testid="metric-loading"]');
+ expect(loadingEl).toBeTruthy();
+ });
+ });
+
+ it('should show error state when data fetch fails', async () => {
+ const dataSource = {
+ find: vi.fn().mockRejectedValue(new Error('Cube name is required')),
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const errorEl = container.querySelector('[data-testid="metric-error"]');
+ expect(errorEl).toBeTruthy();
+ });
+
+ expect(screen.getByText('Cube name is required')).toBeInTheDocument();
+ });
+
+ it('should show error state when aggregate fails', async () => {
+ const dataSource = {
+ aggregate: vi.fn().mockRejectedValue(new Error('Connection refused')),
+ find: vi.fn(),
+ };
+
+ const propsWithAggregate = {
+ ...baseProps,
+ aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const errorEl = container.querySelector('[data-testid="metric-error"]');
+ expect(errorEl).toBeTruthy();
+ });
+
+ expect(screen.getByText('Connection refused')).toBeInTheDocument();
+ });
+
+ it('should display fetched value from find()', async () => {
+ const dataSource = {
+ find: vi.fn().mockResolvedValue({
+ data: [
+ { name: 'A', amount: 100 },
+ { name: 'B', amount: 200 },
+ { name: 'C', amount: 300 },
+ ],
+ }),
+ };
+
+ render(
+
+
+ ,
+ );
+
+ // When no aggregate config, it falls back to counting records
+ await waitFor(() => {
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+ });
+
+ it('should display aggregated value from aggregate()', async () => {
+ const dataSource = {
+ aggregate: vi.fn().mockResolvedValue([
+ { stage: 'All', amount: 652000 },
+ ]),
+ find: vi.fn(),
+ };
+
+ const propsWithAggregate = {
+ ...baseProps,
+ aggregate: { field: 'amount', function: 'sum', groupBy: '_all' },
+ };
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('652000')).toBeInTheDocument();
+ });
+
+ // Should have called aggregate, not find
+ expect(dataSource.aggregate).toHaveBeenCalledWith('opportunity', {
+ field: 'amount',
+ function: 'sum',
+ groupBy: '_all',
+ filter: undefined,
+ });
+ });
+
+ it('should render label even in error state', async () => {
+ const dataSource = {
+ find: vi.fn().mockRejectedValue(new Error('Server error')),
+ };
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Server error')).toBeInTheDocument();
+ });
+
+ // Label should still be visible
+ expect(screen.getByText('Total Revenue')).toBeInTheDocument();
+ });
+
+ it('should use prop dataSource over context dataSource', async () => {
+ const contextDs = {
+ find: vi.fn().mockResolvedValue({ data: [{ a: 1 }] }),
+ };
+ const propDs = {
+ find: vi.fn().mockResolvedValue({ data: [{ a: 1 }, { a: 2 }] }),
+ };
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ expect(propDs.find).toHaveBeenCalled();
+ expect(contextDs.find).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx
index 6dd0c5a40..af99a24a4 100644
--- a/packages/plugin-dashboard/src/index.tsx
+++ b/packages/plugin-dashboard/src/index.tsx
@@ -11,6 +11,7 @@ import { DashboardRenderer } from './DashboardRenderer';
import { DashboardGridLayout } from './DashboardGridLayout';
import { MetricWidget } from './MetricWidget';
import { MetricCard } from './MetricCard';
+import { ObjectMetricWidget } from './ObjectMetricWidget';
import { PivotTable } from './PivotTable';
import { ObjectPivotTable } from './ObjectPivotTable';
import { ObjectDataTable } from './ObjectDataTable';
@@ -18,7 +19,7 @@ import { DashboardConfigPanel } from './DashboardConfigPanel';
import { WidgetConfigPanel } from './WidgetConfigPanel';
import { DashboardWithConfig } from './DashboardWithConfig';
-export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard, PivotTable, ObjectPivotTable, ObjectDataTable, DashboardConfigPanel, WidgetConfigPanel, DashboardWithConfig };
+export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard, ObjectMetricWidget, PivotTable, ObjectPivotTable, ObjectDataTable, DashboardConfigPanel, WidgetConfigPanel, DashboardWithConfig };
// Register dashboard component
ComponentRegistry.register(
@@ -83,6 +84,26 @@ ComponentRegistry.register(
}
);
+// Register object-aware metric widget (async data loading with error states)
+ComponentRegistry.register(
+ 'object-metric',
+ ObjectMetricWidget,
+ {
+ namespace: 'plugin-dashboard',
+ label: 'Object Metric',
+ category: 'Dashboard',
+ inputs: [
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
+ { name: 'label', type: 'string', label: 'Label' },
+ { name: 'aggregate', type: 'object', label: 'Aggregate', description: 'Aggregation config: { field, function, groupBy }' },
+ { name: 'icon', type: 'string', label: 'Icon (Lucide name)' },
+ ],
+ defaultProps: {
+ label: 'Metric',
+ }
+ }
+);
+
// Register pivot table component
ComponentRegistry.register(
'pivot',
@@ -205,6 +226,7 @@ export const dashboardComponents = {
DashboardGridLayout,
MetricWidget,
MetricCard,
+ ObjectMetricWidget,
PivotTable,
ObjectPivotTable,
ObjectDataTable,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1d57368a3..239e100a9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1319,6 +1319,9 @@ importers:
'@object-ui/types':
specifier: workspace:*
version: link:../types
+ lucide-react:
+ specifier: ^0.577.0
+ version: 0.577.0(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4