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
47 changes: 47 additions & 0 deletions .changeset/metric-widget-dom-spread-4357.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@object-ui/plugin-dashboard': patch
---

KPI cards no longer write their own schema onto the DOM — `MetricWidget` and
`MetricCard` keep `SchemaRenderer`'s schema-shaped props out of the `...props`
spread (objectui#4357).

Both components are two things at once: an SDUI block reached through
`SchemaRenderer`, and a plain React component a host may render directly. The
React half wants a `...props` spread on its root so callers can pass `aria-*`,
`data-*`, `id`, `role`. The SDUI half means that spread also received the node's
own metadata — and React writes unknown lowercase attributes straight to the DOM,
stringifying object values. Every KPI card therefore carried
`schema="[object Object]"`, and a widget authored with events, a binding or a
props container carried `events="[object Object]"`, `bind="data.revenue"` and
`props="[object Object]"` beside it.

Seven props were measured arriving at the call site that are not HTML attribute
names — `schema`, `events`, `props`, `bind`, `ariaLabel`, `ariaDescribedBy` (the
last two are the camelCase authored forms of ARIA the renderer already emits in
their dashed spelling) and `dataSource`. They are destructured out; the spread
survives untouched for everything that IS a DOM attribute: `id`, `name`, `role`,
`disabled`, `aria-*`, `data-*`, `className`. Nothing else about the render moves
— no text, no class, no element.

`dataSource` is the one that only a live dashboard shows. It is not a schema key
(the renderer strips the schema's own `dataSource` binding by name); it is the
injected adapter `DashboardRenderer` hands its `SchemaRenderer` call, which
arrives through the renderer's trailing props. Every fixture in this package
renders without an adapter, so it read `undefined` and wrote nothing — while
every deployment that actually loads data put `datasource="[object Object]"` on
the card. The pin renders a dashboard with an adapter so the case that only
production had is now a test.

The cost of this was never visible; it was that the defect poisoned the
assertion this area attracts. objectui#4163 pins
`not.toContain('[object Object]')` on the dashboard grid, and objectui#4032
wanted the same pin on the metric path but could not write it: the card carried
the attribute before and after any i18n fix, so the container assertion was red
for a reason unrelated to labels and the tempting repair was to loosen it. That
suite asserted on the card heading instead, with a comment. The workaround is
now removed and the container assertion is back.

The exported `MetricWidgetProps` / `MetricCardProps` interfaces are unchanged —
the components' accepted props widen only by the optional, ignored
`SchemaHostProps` keys, so no consumer type narrows.
19 changes: 16 additions & 3 deletions packages/plugin-dashboard/src/MetricCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { cn } from '@object-ui/components';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { I18nLabel } from '@object-ui/types';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
import type { SchemaHostProps } from './schemaHostProps';

export interface MetricCardProps {
/**
Expand All @@ -36,7 +37,7 @@ export interface MetricCardProps {
* MetricCard - Standalone metric card component for dashboard KPIs
* Displays a metric value with optional icon, trend indicator, and description
*/
export const MetricCard: React.FC<MetricCardProps> = ({
export const MetricCard: React.FC<MetricCardProps & SchemaHostProps> = ({
title,
value,
icon,
Expand All @@ -46,15 +47,27 @@ export const MetricCard: React.FC<MetricCardProps> = ({
className,
loading,
error,
...props
// Schema-shaped props `SchemaRenderer` injects, destructured out so the
// spread below cannot write them to the DOM (objectui#4357). Named and
// measured in `./schemaHostProps`; `schema` alone put a
// `schema="[object Object]"` attribute on every card. The rest spread
// survives — it is the component's genuine DOM/aria passthrough.
schema: _schema,
bind: _bind,
events: _events,
props: _propsBag,
ariaLabel: _ariaLabel,
ariaDescribedBy: _ariaDescribedBy,
dataSource: _dataSource,
...domProps
}) => {
// Resolve icon via lazy resolver — each icon ships as its own micro-chunk
const IconComponent = icon ? getLazyIcon(icon) : null;
// Label text follows the active UI language (not the tenant's number locale).
const { language } = useObjectTranslation();

return (
<Card className={cn("h-full", className)} {...props}>
<Card className={cn("h-full", className)} {...domProps}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{pickLocalized(title, language)}
Expand Down
19 changes: 16 additions & 3 deletions packages/plugin-dashboard/src/MetricWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { I18nLabel } from '@object-ui/types';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
import { VARIANT_ICON_CLASSES, VARIANT_TEXT_CLASSES, type MetricColorVariant } from './colorVariants';
import type { SchemaHostProps } from './schemaHostProps';

const TREND_LABEL_DEFAULTS: Record<string, string> = {
'dashboard.trend.vsLastQuarter': 'vs last quarter',
Expand Down Expand Up @@ -210,8 +211,20 @@ export const MetricWidget = ({
suffix,
onClick,
variant = 'card',
...props
}: MetricWidgetProps) => {
// Schema-shaped props `SchemaRenderer` injects, destructured out so the
// spread below cannot write them to the DOM (objectui#4357). Named and
// measured in `./schemaHostProps`; `schema` alone put a
// `schema="[object Object]"` attribute on every KPI card. The rest spread
// survives — it is the component's genuine DOM/aria passthrough.
schema: _schema,
bind: _bind,
events: _events,
props: _propsBag,
ariaLabel: _ariaLabel,
ariaDescribedBy: _ariaDescribedBy,
dataSource: _dataSource,
...domProps
}: MetricWidgetProps & SchemaHostProps) => {
const iconClasses = VARIANT_ICON_CLASSES[colorVariant] || VARIANT_ICON_CLASSES.default;
const { t: tTrend } = useTrendT();
// Two locale channels, deliberately distinct. `useDisplayLocale` is the
Expand Down Expand Up @@ -298,7 +311,7 @@ export const MetricWidget = ({
onClick();
}
} : undefined}
{...props}
{...domProps}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium truncate">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,6 @@ function dashboard(widget: Record<string, unknown>): DashboardComponentSchema {
} as unknown as DashboardComponentSchema;
}

/** The KPI card's heading row — the slot every assertion below is about. */
function cardHeading(): HTMLElement {
const el = document.querySelector('.tracking-tight.text-sm.font-medium');
if (!el) throw new Error('metric card heading not rendered');
return el as HTMLElement;
}

function renderIn(language: string, schema: DashboardComponentSchema) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false, resources: ZH_BUNDLE }}>
Expand All @@ -116,7 +109,7 @@ describe('DashboardRenderer — KPI cards translate from the widget convention k
});

it('(b) resolves an inline per-locale title map, and never leaks the widget type', () => {
renderIn('zh', dashboard({
const { container } = renderIn('zh', dashboard({
id: 'unkeyed',
type: 'metric',
title: { en: 'Total Revenue', 'zh-CN': '总收入' },
Expand All @@ -128,25 +121,27 @@ describe('DashboardRenderer — KPI cards translate from the widget convention k
// so `|| widgetType` rendered the literal string "metric".
expect(screen.queryByText('metric')).toBeNull();
// And the sibling surface's failure mode, which this path never had.
// Asserted on the HEADING, not on `container.innerHTML`: the card also
// carries an unrelated `schema="[object Object]"` DOM attribute, present on
// this path before and after this change and on plain-string titles too —
// `SchemaRenderer` hands the widget schema down and `MetricWidget` spreads
// `...props` onto the `Card`. Filed separately rather than widened into
// this pin, which would have made the assertion pass for the wrong reason.
expect(cardHeading().innerHTML).not.toContain('[object Object]');
// Asserted on the CONTAINER, which is where this pin belongs and where
// #4163 puts the sibling one. It used to be scoped to the card heading:
// the card carried an unrelated `schema="[object Object]"` attribute on
// every render — `SchemaRenderer` hands the widget schema down and
// `MetricWidget` spread `...props` onto the `Card` — so the container
// assertion was red for a reason that had nothing to do with labels, and
// the tempting repair was to delete it. objectui#4357 removed the leak at
// its source (`src/schemaHostProps.ts`), so the assertion is writable now.
expect(container.innerHTML).not.toContain('[object Object]');
});

it('(b2) resolves the inline map for `en` too — the authored en text, not the raw map', () => {
renderIn('en', dashboard({
const { container } = renderIn('en', dashboard({
id: 'unkeyed',
type: 'metric',
title: { en: 'Total Revenue', 'zh-CN': '总收入' },
options: { value: 1930000 },
}));

expect(screen.getByText('Total Revenue')).toBeTruthy();
expect(cardHeading().innerHTML).not.toContain('[object Object]');
expect(container.innerHTML).not.toContain('[object Object]');
});

it('(b3) prefers the bundle over the inline map when both exist', () => {
Expand Down
Loading
Loading