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 ? ( +
+ + {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 ? ( +
+ + {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