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
17 changes: 17 additions & 0 deletions .changeset/11389-retire-metric-subcaption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-dashboard': minor
'@object-ui/i18n': minor
'@object-ui/sdui-parser': minor
---

The metric sub-caption is retired on the reader side (objectui#11389, ruling C, the objectui half after `@objectstack/spec` 17.7.0). **BREAKING** for any dashboard that drew a caption under a metric's value.

A metric tile used to draw a sub-caption under its value from the widget's `options.description`, translated by a client bundle entry at `dashboards.NAME.widgets.ID.subCaption`. The spec never declared that options key, and its only writer was the server's `translateDashboard` overlay. `@objectstack/spec` 17.7.0 removed the overlay and refuses a `subCaption` translation entry by name. objectui now stops reading both:

- **`@object-ui/plugin-dashboard`.** No metric tile draws a sub-caption, on either dashboard surface (`DashboardRenderer`, `DashboardGridLayout`), whether the tile is dataset-bound or a stored inline metric. An authored `options.description` (a string or a per-locale map) draws nothing, and neither does a bundle `subCaption` entry. A widget's one authored description, `widget.description`, still draws as the card-header subtitle on `DashboardRenderer`, translated through the widget's `description` bundle key. Nothing on the package entry is removed: the sub-caption resolver module and `DatasetWidget`'s `subCaption` prop were internal.
- **`@object-ui/i18n`.** `useObjectLabel()` no longer returns `widgetSubCaption`. This removes a member of a published hook's return value: a caller that destructured it no longer compiles, and has nothing to call instead, because the key it read is refused by the spec.
- **`@object-ui/sdui-parser`.** `CONSUMED_WIDGET_OPTION_KEYS` drops `'description'` and is now exactly the five keys the spec declares (`dateGranularity`, `limit`, `sortBy`, `sortOrder`, `stageOrder`). So `validateTree` reports an authored `options.description` on a dataset-bound dashboard widget as an `unconsumed-widget-option` **warning**, where it used to report nothing. It is a warning, not an error, and the widget's `suppressWarnings` escape hatch still applies.

**Clause-②: no (narrowing).** One export member is removed (`useObjectLabel().widgetSubCaption`) and one exported constant loses a member (`CONSUMED_WIDGET_OPTION_KEYS`). Nothing is added and no accepted input widens.

If a caption under a metric's value is wanted again, it returns as a declared widget-level key outside `options`, not as `options.description` (ruling C).
5 changes: 4 additions & 1 deletion content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ into it as `runtimeFilter`.
> `gauge`, `solid-gauge`, `kpi`, `bullet`, or a widget with no `type`) whose
> `options.data` is a `{ "provider": "object", … }` query shows the
> retired-format prompt below instead of its number (objectui#11525), as a
> `pivot` widget with that query already did.
> `pivot` widget with that query already did. And one key no longer draws on
> any widget: `options.description`, the metric sub-caption, is retired at both
> ends (objectui#11389), so a stored one draws nothing. A widget's one authored
> description is `widget.description`, the card-header subtitle.

> **Retired: the top-level inline analytics shape.** `object` +
> `categoryField` / `valueField` / `aggregate` on the widget itself (and the
Expand Down
15 changes: 12 additions & 3 deletions content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,14 @@ required), the renderers read exactly:
| `sortBy` / `sortOrder` | orders by a projected dimension or measure |
| `limit` | caps the row count |
| `stageOrder` | explicit stage order for `funnel` / `pyramid` |
| `description` | metric-card sub-caption; the `widgets.{id}.subCaption` translation channel writes this key |

These are the five keys the spec declares, and nothing else is read.
`options.description` is not among them: it was the metric-card sub-caption,
retired at both ends (objectui#11389). `@objectstack/spec` no longer writes
it from a translation, and objectui no longer draws it. A widget has one
authored description, `widget.description`, drawn as the card-header subtitle.
If a caption under a metric's value returns, it returns as a declared
widget-level key, not as an `options` key.

Every other `options` key on a dataset-bound widget **reaches no renderer**.
The authoring validator (`validateTree` in `@object-ui/sdui-parser`) reports
Expand Down Expand Up @@ -518,8 +525,10 @@ which lists the records for either form and draws no report.
## Translations — a served dashboard is translated once

Widget texts have a client translation channel: bundle keys under
`dashboards.<name>.widgets.<id>` (`title`, `description`, `subCaption`), plus
`dashboards.<name>.label` and `.description`. A document read from
`dashboards.<name>.widgets.<id>` (`title`, `description`), plus
`dashboards.<name>.label` and `.description`. There is no `subCaption` key:
the translation schema in `@objectstack/spec` refuses it by name in a bundle,
and the renderer reads no such key (objectui#11389). A document read from
ObjectStack's `/meta` route does not need it. The server has already applied
those keys for the request's locale, and it keeps a published edit over them:
an explicit override beats the packaged default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
*
* Directions, written before the run: the edited cells RED before the change
* (the bundle answered), GREEN after; the unedited cells GREEN on both sides.
*
* The widget's retired sub-caption (objectui#11389, ruling C) stays in the
* fixtures — an `options.description` on the served widget and the bundle's
* `subCaption` entries — and every case pins that none of it draws on the
* console page.
*/

import * as React from 'react';
Expand Down Expand Up @@ -63,6 +68,7 @@ const BUNDLE = {
system_overview: {
label: 'System Overview',
description: 'Platform health at a glance',
// `subCaption` is refused by the installed spec; kept to pin that it draws nothing.
widgets: { widget_total_users: { title: 'Total Users', description: 'Active accounts', subCaption: 'Across all organizations' } },
},
},
Expand All @@ -86,15 +92,17 @@ interface Texts {
description: string;
title: string;
widgetDescription: string;
subCaption: string;
}

/** The retired sub-caption, authored and bundled: none of it may draw. */
const RETIRED_SUB_CAPTION = 'All orgs (edited-11295)';
const RETIRED_TEXTS = [RETIRED_SUB_CAPTION, 'Across all organizations', '覆盖所有组织'];

const EDITED: Texts = {
label: 'Operations board (edited-11295)',
description: 'What the ops team watches (edited-11295)',
title: 'Total Users (edited-11295)',
widgetDescription: 'Accounts (edited-11295)',
subCaption: 'All orgs (edited-11295)',
};

const SERVED_UNEDITED: Record<'en' | 'zh-CN', Texts> = {
Expand All @@ -103,14 +111,12 @@ const SERVED_UNEDITED: Record<'en' | 'zh-CN', Texts> = {
description: 'Platform health at a glance',
title: 'Total Users',
widgetDescription: 'Active accounts',
subCaption: 'Across all organizations',
},
'zh-CN': {
label: '系统概览',
description: '平台健康一览',
title: '用户总数',
widgetDescription: '活跃账户',
subCaption: '覆盖所有组织',
},
};

Expand All @@ -126,7 +132,7 @@ function served(texts: Texts) {
type: 'kpi',
title: texts.title,
description: texts.widgetDescription,
options: { value: 42, description: texts.subCaption },
options: { value: 42, description: RETIRED_SUB_CAPTION },
},
],
};
Expand Down Expand Up @@ -167,6 +173,7 @@ describe('DashboardView — a served dashboard is drawn as served (objectui#1129
expect(h1.textContent).toBe(EDITED.label);
for (const text of Object.values(EDITED)) expect(screen.getAllByText(text).length).toBeGreaterThan(0);
for (const text of Object.values(SERVED_UNEDITED[language])) expect(screen.queryByText(text)).toBeNull();
for (const text of RETIRED_TEXTS) expect(screen.queryByText(text)).toBeNull();
});

it.each(['en', 'zh-CN'] as const)('%s: an unedited dashboard shows the translation the server put in', async (language) => {
Expand All @@ -175,5 +182,6 @@ describe('DashboardView — a served dashboard is drawn as served (objectui#1129

expect(h1.textContent).toBe(texts.label);
for (const text of Object.values(texts)) expect(screen.getAllByText(text).length).toBeGreaterThan(0);
for (const text of RETIRED_TEXTS) expect(screen.queryByText(text)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,14 @@ describe('useObjectLabel identity (objectui#5564)', () => {
// by objectui#7219 (ruled 2026-09-02), taking the count from 27 to 26.
// objectui#11344 added `actionOutcome` (an action's per-outcome success
// copy), taking it back to 27. objectui#11696 added `objectPluralLabel`
// (the name of an object's list), taking it to 28. A new resolver must land
// (the name of an object's list), taking it to 28. objectui#11389 retired
// `widgetSubCaption` with the metric sub-caption (ruling C), taking it to
// 27. A new resolver must land
// on both paths at once, because there is only one path — and a retired one
// leaves both at once for the same reason, which is what the equality above
// measures and this count anchors to an absolute.
expect(Object.keys(unbound.seen[0])).toHaveLength(28);
expect(Object.keys(unbound.seen[0])).toHaveLength(27);
expect(Object.keys(unbound.seen[0])).not.toContain('widgetSubCaption');
expect(typeof unbound.seen[0].objectLabel).toBe('function');
expect(unbound.seen[0].objectLabel({ name: 'lead', label: 'Lead' })).toBe('Lead');
});
Expand Down
33 changes: 5 additions & 28 deletions packages/i18n/src/useObjectLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,41 +463,18 @@ export function useObjectLabel() {
* Resolve translated widget description within a dashboard.
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
* Returns undefined when neither metadata nor translation provides one.
*
* This is a widget's ONE translated description. The sibling sub-caption
* key, `widgets.{widgetId}.subCaption`, is retired with its resolver
* (objectui#11389, ruling C): `@objectstack/spec` 17.7.0 refuses it by
* name in a translation bundle, and nothing here reads it.
*/
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => {
const fb = fallback ?? '';
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
return resolved || undefined;
},

/**
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
* Returns undefined when neither metadata nor translation provides one.
*
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
* The KPI card renders two authored strings from two different fields — the
* shared card header's `widget.description`, and the sub-caption under the
* value, which is authored as `widget.options.description` — and the
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
* translation entry silently retarget the other field on any widget type
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
* type except the self-contained `metric`).
*
* `subCaption` is the member objectstack#8056 added to the widget
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
* The server-side resolver reads the SAME key and overlays it onto
* `options.description` (`translateDashboard`), so a document served
* through `/meta` and a document translated here land on the same string —
* this is the client half of one convention, not a second dialect.
*/
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => {
const fb = fallback ?? '';
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.subCaption`), fb);
return resolved || undefined;
},

/**
* Resolve translated page label, falling back to pageDef.label.
* Convention: `{ns}.pages.{pageName}.label`.
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ keeps a published edit over that catalog: an explicit override beats the
packaged default. Pass `localized={true}` when your document came from such a
read. The renderer then draws these texts as given:

- the widget `title`, `description` and sub-caption (`options.description`);
- the widget `title` and `description`;
- its own header `label` and `description`.

An inline per-locale map is still collapsed to the active language. The client
Expand Down
30 changes: 5 additions & 25 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ import {
resolveWidgetType,
toDashboardNodeType,
unsupportedWidgetSchema,
withoutRetiredSubCaption,
type DashboardWidgetSlotEntry,
} from './widgetDispatch';
import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget, isRetiredEnvelopeNode } from './legacyRetiredWidget';
import { DatasetWidget } from './DatasetWidget';
import type { DashboardChartRenderSchema } from './chartRenderHandoff';
import { useWidgetSubCaption } from './widgetSubCaption';
import { useDashboardAutoRefresh } from './useDashboardAutoRefresh';

/** Bridges editMode transitions to the ObjectUI DnD system when a DndProvider is present. */
Expand Down Expand Up @@ -175,22 +175,6 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
composeSeriesLabel(t, fieldLabel, objectName, yField, aggFn),
[t, fieldLabel],
);
/**
* The metric tile's sub-caption resolver — objectui#8889.
*
* This surface routes a dataset-bound widget to `DatasetWidget` exactly as
* `DashboardRenderer` does (objectui#4614), so it owes that component the
* same resolved sub-caption. It is the SAME hook the sibling calls, not a
* second copy of the composition: the field's invariant is that its two
* channels "can never disagree", and two independent resolvers are precisely
* how they would.
*
* `schema.name` is the dashboard name every convention key on this surface is
* built from (`BaseSchema.name`, which `DashboardComponentSchema` extends).
* Absent it the hook degrades to the authored value alone — the same silent
* degradation the sibling's title/description lookups perform.
*/
const tWidgetSubCaption = useWidgetSubCaption(schema.name);
// The refresh indicator, the manual handler and the auto-refresh timer come
// from the one implementation this component shares with `DashboardRenderer`
// (objectui#8820), which is also the only place `refreshIntervalSeconds` is
Expand Down Expand Up @@ -415,7 +399,10 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
// Its declared type is `DashboardMetricNodeSchema`, the
// `CustomNodeRegistry` entry `./widgetDispatch` adds (objectui#11466).
type: DASHBOARD_NODE_TYPES.metric,
...options,
// The card draws no sub-caption from `options` (objectui#11389, ruling
// C): the spread drops the retired `description` key, the same way
// `DashboardRenderer`'s metric arm does.
...withoutRetiredSubCaption(options),
label,
value: options.value ?? rows[0]?.[valueField] ?? '—',
};
Expand Down Expand Up @@ -711,13 +698,6 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
? <DatasetWidget
widget={datasetWidget}
dataSource={dataSource}
/* objectui#8889 — dispatch site 2 of 2, and the half that
objectui#4614 exists to stop anyone from forgetting: the
sibling passing this alone would fix one surface and leave
this one silently unchanged. `?? null` says "a surface
resolved it, to nothing", which is NOT the same as the
prop being absent — see the prop's docblock. */
subCaption={tWidgetSubCaption(datasetWidget) ?? null}
/>
: <SchemaRenderer schema={componentSchema} />}
</div>
Expand Down
Loading
Loading