diff --git a/.changeset/11389-retire-metric-subcaption.md b/.changeset/11389-retire-metric-subcaption.md new file mode 100644 index 0000000000..dc1a43011c --- /dev/null +++ b/.changeset/11389-retire-metric-subcaption.md @@ -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). diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index 571a778a09..047492bcea 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -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 diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 61df2fa31a..a8be14007d 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -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 @@ -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..widgets.` (`title`, `description`, `subCaption`), plus -`dashboards..label` and `.description`. A document read from +`dashboards..widgets.` (`title`, `description`), plus +`dashboards..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. diff --git a/packages/app-shell/src/views/DashboardView.servedLocalized-11295.test.tsx b/packages/app-shell/src/views/DashboardView.servedLocalized-11295.test.tsx index 026bdac2ad..cef58aeced 100644 --- a/packages/app-shell/src/views/DashboardView.servedLocalized-11295.test.tsx +++ b/packages/app-shell/src/views/DashboardView.servedLocalized-11295.test.tsx @@ -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'; @@ -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' } }, }, }, @@ -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> = { @@ -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: '覆盖所有组织', }, }; @@ -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 }, }, ], }; @@ -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) => { @@ -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(); }); }); diff --git a/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx index 4e5bac1584..f8c03bad43 100644 --- a/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx +++ b/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx @@ -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'); }); diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index 159091acc0..7799ed3b22 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -463,6 +463,11 @@ 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 ?? ''; @@ -470,34 +475,6 @@ export function useObjectLabel() { 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`. diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 4c53608c59..8156509aa5 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -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 diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 9f6745b757..88d7d29cb5 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -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. */ @@ -175,22 +175,6 @@ export const DashboardGridLayout: React.FC = ({ 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 @@ -415,7 +399,10 @@ export const DashboardGridLayout: React.FC = ({ // 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] ?? '—', }; @@ -711,13 +698,6 @@ export const DashboardGridLayout: React.FC = ({ ? : } diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 13e19fb1c7..df5be43499 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -41,11 +41,10 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils'; -import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, type DashboardWidgetSlotEntry } from './widgetDispatch'; +import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, 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'; import { DashboardFilterBar } from './DashboardFilterBar'; @@ -292,8 +291,8 @@ export interface DashboardRendererProps * it. * * When true, the texts the server translated — the dashboard `label` and - * `description`, and each widget's `title`, `description` and sub-caption - * (`options.description`) — are drawn as given: an inline per-locale map is + * `description`, and each widget's `title` and `description` — are drawn as + * given: an inline per-locale map is * still collapsed to the active language, but no client bundle lookup runs * over them. A second pass over a served value is what let the packaged * catalog win again, client-side, over a published edit (objectui#11295). @@ -502,53 +501,6 @@ const DashboardRendererInner = forwardRef { const runner = engine.getRunner(); @@ -915,16 +867,6 @@ const DashboardRendererInner = forwardRef : } diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index c97b496299..ba0b351c4d 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -86,7 +86,7 @@ import { type DatasetDrillRange, } from '@object-ui/core'; import { cn, Skeleton, ChartSkeleton, GridSkeleton, RefreshIndicator } from '@object-ui/components'; -import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplayLocale, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; +import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; import { AlertTriangle, ShieldAlert, Download, ArrowUpIcon, ArrowDownIcon, MinusIcon, ChevronsUpDown, ChevronUp, ChevronDown } from 'lucide-react'; // objectui#7063 — the default empty state is stated ONCE for the dashboard // surface (see that component's header for why it is dashboard-local). @@ -538,30 +538,20 @@ const CHART_TYPE_MAP: Record = { export { chartConfigPresentation } from '@object-ui/core'; /** - * The sub-caption a dashboard surface already resolved for this tile - * (objectui#8889). Three states, and the difference between two of them is - * load-bearing: + * A dataset-bound widget: a metric tile, a table / pivot, or a chart, drawn + * from one dataset query. * - * - **omitted (`undefined`)** — nobody upstream resolved it. This component is - * rendering outside a dashboard surface (a standalone host, a preview, a - * test), so there is no dashboard `name` in existence and therefore no - * bundle key to look up. The AUTHORED limb is resolved locally, which is - * exactly what objectui#7293 landed and what keeps rendering. - * - **a string** — that is the answer; render it. - * - **`null`** — a surface DID resolve it, to nothing. Render nothing, and in - * particular do NOT fall back to the authored value: a bundle entry that - * resolves to empty is the bundle winning, and the inline arms of - * `getComponentSchema()` render nothing in that same case. Falling back here - * is how the two surfaces would start to disagree. - * - * ⛔ Never widened to "the dashboard name" or "the widget's translation node". - * Handing this component the INPUTS would put a second copy of the - * authored-vs-bundle composition inside it, and the field's own invariant — "a - * bundle entry always wins over an inline map and the two channels can never - * disagree" — needs a single decision point to be true. That point is - * `useWidgetSubCaption`; what arrives here is its answer. + * ⛔ No sub-caption prop, and no read of a `description` key in the options + * bag: the metric sub-caption is retired at both ends (objectui#11389, ruling + * C, after objectstack's half in `@objectstack/spec` 17.7.0). A widget keeps + * one authored description, `widget.description`, which the dashboard surface + * draws as the card-header subtitle. If a caption under a metric's value + * returns, it returns as a declared widget-level key outside the options bag, + * never as a key inside it. The options census in `@object-ui/sdui-parser` + * reads this file's text, comments included, which is why this note does not + * spell the bag's member access. */ -export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; dataSource: unknown; subCaption?: string | null }) { +export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: unknown }) { const datasetName = String(widget?.dataset ?? ''); const dimensions: string[] = useMemo(() => (Array.isArray(widget?.dimensions) ? widget.dimensions.filter(Boolean) : []), [widget]); const values: string[] = useMemo(() => (Array.isArray(widget?.values) ? widget.values.filter(Boolean) : []), [widget]); @@ -715,12 +705,6 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; // dependency array to add it to; the sites below are all in the render body // and re-run whenever the provider changes. const displayLocale = useDisplayLocale(); - // The UI LANGUAGE, deliberately distinct from `displayLocale` above: that one - // is the NUMBER locale (objectui#4566 / #4033 — the tenant regional default - // outranks the UI language), this one is what authored TEXT follows. Swapping - // either for the other silently changes the other surface's behaviour, which - // is why `MetricWidget` keeps the same two channels apart by name. - const { language } = useObjectTranslation(); // ADR-0021 dual-form: the widget's presentation-scope `filter` must flow into // the dataset query as `runtimeFilter`, or a dataset-bound widget renders the @@ -1234,65 +1218,14 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; // `undefined`, which `cn` drops: the markup of every widget that never // declared the key stays byte-identical. const accentClass = metricAccentTextClass(widget?.colorVariant); - // ── The declared sub-caption (objectui#7293) ─────────────────────────── - // `options.description` is the metric tile's SUB-CAPTION slot. Before - // #7293 it was wired end to end and reached nothing here: it has its own - // translation key (`{ns}.dashboards.{dash}.widgets.{id}.subCaption`, - // objectui#4032 item 4 / objectstack#8056), the server's - // `translateDashboard` OVERLAYS that translation onto this very key, and - // `DashboardRenderer.tWidgetSubCaption` resolved it — but only onto the two - // INLINE arms of `getComponentSchema`. - // - // ⚠️ Wired, not DECLARED (objectui#11070 round 11). `@objectstack/spec`'s - // `DashboardWidgetOptionsSchema` has no `description` member; its open bag - // admits the key without judging it. `translateDashboard` writes it on the - // served path (objectstack's `check:widget-option-census` ledgers it as an - // undeclared resolver output). objectui's strict authoring face refuses an - // authored one with the inline-dialect keys (objectui#11228 ruling C). - // Which way it goes — a spec declaration, a declared home for the overlay, - // or retiring both ends — is an open decision on objectui#11070; this read - // stays meanwhile, because its writer is live. - // `dataset` is REQUIRED on `DashboardWidgetSchema` (verified against the - // published @objectstack/spec@17.4.0: required keys are id/dataset/values), - // so every spec-legal widget renders HERE instead, and every author who - // wrote a sub-caption got silence — the ADR-0049 declared-but-unenforced - // shape, same as `colorVariant` above. - // - // ── The BUNDLE limb (objectui#8889) ──────────────────────────────────── - // #7293 (above) landed the AUTHORED limb here, reading the key at the - // caption row rather than taking a prop, and its reason was sound at the - // time: BOTH dashboard surfaces route a dataset-bound widget to this - // component (`DashboardRenderer` and `DashboardGridLayout`, objectui#4614), - // so a prop from ONE dispatch site fixes one surface and leaves the other - // silently unchanged (objectui#4614 is that lesson's original card). - // - // What it could not deliver is the SECOND limb: a client i18n bundle entry - // at `{ns}.dashboards.{dash}.widgets.{id}.subCaption` overriding the - // authored value. That limb needs the dashboard NAME, and this component - // does not know which dashboard it is on — it is handed a widget, not a - // position. So an app-bundle dashboard whose sub-caption was written ONLY - // in the bundle (no `options.description` at all) rendered nothing here, - // even though `tWidgetSubCaption`'s own docblock calls that shape - // legitimate: "A translation with no authored counterpart is legitimate and - // matches the server." - // - // The answer now arrives resolved, from `useWidgetSubCaption` — ONE - // composition, called by BOTH dispatch sites (the #4614 requirement is met - // by changing both, not by moving the read down here). See the `subCaption` - // prop's docblock for the three states and why `null` must not fall back. - // - // The local limb below is what remains of #7293, and it still runs for a - // host that passes no prop. `pickLocalized` (the objectui#4208 seam), not a - // `typeof === 'string'` test: an authored inline per-locale map is the - // vocabulary this field admits, and re-implementing a narrower resolver - // here is exactly the fourth dialect objectstack#4115 exists to prevent - // (objectui#4032 is what a private resolver that could not read the map - // already cost). A miss answers `''`, which collapses to `undefined` and - // renders NO node at all — a tile that declares no sub-caption keeps - // byte-identical markup. - const resolvedSubCaption = subCaption === undefined - ? (pickLocalized(options.description, language) || undefined) - : (subCaption || undefined); + // ── No sub-caption (objectui#11389, ruling C) ────────────────────────── + // The tile draws the value, the delta and the measure label, and nothing + // from the options bag. Its `description` key was the sub-caption + // (objectui#7293), fed by the server's `subCaption` overlay and a client + // bundle limb; both ends are retired, and the spec never declared the key. + // An authored one now draws the parser's `unconsumed-widget-option` warning + // instead of a caption node. The card-header subtitle, `widget.description`, + // is the surface's to draw, not this tile's. return ( // Positioned only while a re-read is in flight, for the refresh bar: the // idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`, @@ -1317,9 +1250,6 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; )} {headerLabel(tileMeasure)} - {resolvedSubCaption && ( - {resolvedSubCaption} - )} ); } diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricSubCaption.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricSubCaption.test.tsx index 7288ebf76a..87dbe05f1a 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricSubCaption.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricSubCaption.test.tsx @@ -7,52 +7,29 @@ */ /** - * objectui#4032 item 4 — the KPI card's SUB-CAPTION. + * objectui#11389 — the metric card's SUB-CAPTION is retired at both ends + * (ruling C, which reverses objectstack#5428 item 4). * - * The metric card renders two authored strings, and they are two DIFFERENT - * authored fields with two DIFFERENT convention keys (objectstack#5428 item-4 - * ruling, 2026-08-06: 「两个作者字段两个 key」): + * This file used to pin the opposite (objectui#4032 item 4): an authored + * `options.description` drew as the line under a metric's value, and a client + * bundle entry at `dashboards..widgets..subCaption` translated it. The + * spec never declared that options key. Its only writer was objectstack's own + * `translateDashboard` overlay, which `@objectstack/spec` 17.7.0 removed, and + * the same release refuses a `subCaption` bundle entry by name. So the reader + * half goes too, and these are the reversed pins, on the INLINE metric arm of + * both dashboard surfaces: * - * | authored field | rendered as | bundle key | - * |------------------------|--------------------------|-----------------------------------------------| - * | `widget.description` | the SHARED card header | `dashboards..widgets..description` | - * | `options.description` | the KPI card sub-caption | `dashboards..widgets..subCaption` | + * - an authored `options.description`, a plain string or a per-locale map, + * draws nothing; + * - a bundle `subCaption` entry draws nothing, while the bundle `title` on + * the same entry still does (the lit control: bundle lookups are live); + * - `widget.description` still draws as the card-header subtitle on a widget + * that has a card header (the ruling's other pin), translated through the + * widget node's `description` key. * - * `subCaption` is the member objectstack#8056 added to the widget translation - * node (shipped in `@objectstack/spec@17.0.0`, the version this repo pins). - * PR #4358 landed items 1-3 and deliberately STOPPED here, because at the time - * every candidate segment was rejected by the spec and the only accepted key - * was `description` — the shared key the ruling forbids. - * - * The server half already exists: `translateDashboard` overlays `subCaption` - * onto `options.description` on the `/meta` path. These tests pin the CLIENT - * half — the same key path, resolved through the #4358 seam, for the app - * bundles objectui loads into `I18nProvider` itself. - * - * DIRECTIONS, written before the reverse verification was run: - * - * - **(a) bundle `subCaption` → sub-caption: RED before the change.** Nothing - * in the renderer reads `subCaption`; the metric dispatch spreads - * `...options` straight through, so `options.description` reaches - * `MetricWidget` as the raw authored English. - * - **(c) bundle `subCaption` AND `description` on ONE widget: HALF-RED.** The - * shared card header already translates from `description` (#4358), so that - * half is GREEN before the change; the sub-caption half is RED. This is the - * separation pin, and the asymmetry is the point — the two keys must land in - * two different places, and a future tidy-up that collapses them to one key - * would turn (b)/(c) red rather than passing quietly. - * - **(f) bundle beats an inline per-locale map: RED before the change.** - * - **(b) `description` must NOT reach the sub-caption: GREEN on both sides.** - * Nothing routes it today; the pin exists so nothing starts to. - * - **(d) no `subCaption` entry → untouched: GREEN on both sides.** Same - * reference semantics as `title`: an app with no translations keeps the exact - * bytes it renders today, and a widget with no authored sub-caption grows no - * caption row (the resolver must answer `undefined`, never `''`). - * - **(e) inline per-locale map alone: GREEN on both sides.** `MetricWidget` - * already collapses it via `pickLocalized` (#4208's seam). The fix must - * compose the two channels in the SAME fixed order `tWidgetTitle` uses — - * collapse the authored value to the active language FIRST, then offer the - * plain string to the bundle as its fallback — not replace this channel. + * Directions, written before the run: every "draws nothing" case is RED on the + * pre-change code (it drew the caption) and GREEN after; the label, value, + * title and header-subtitle controls are GREEN on both sides. */ import * as React from 'react'; @@ -61,19 +38,17 @@ import { render, screen, cleanup } from '@testing-library/react'; import { I18nProvider } from '@object-ui/i18n'; import type { DashboardComponentSchema } from '@object-ui/types'; // Module scope, never a hook — AGENTS.md §测试纪律. The dashboard renders each -// widget through `SchemaRenderer`, which resolves `metric` / `kpi` from the +// widget through `SchemaRenderer`, which resolves the metric node from the // registry, populated as a side effect of this barrel. -import { DashboardRenderer } from '../index'; +import { DashboardRenderer, DashboardGridLayout } from '../index'; afterEach(cleanup); /** * `crm` is discovered as an app namespace because it carries a `dashboards` - * sub-key — the same discovery every other convention lookup performs. - * - * `revenue` carries BOTH keys with visibly different values, which is what - * makes the separation assertions in (c) meaningful: if the two ever collapse - * onto one key, one of the two strings goes missing. + * sub-key. `revenue` keeps a `subCaption` entry beside its live keys: the + * installed spec refuses that entry, but `I18nProvider` takes a host's + * resources without the schema, so this is the door the pins close. */ const ZH_BUNDLE = { zh: { @@ -86,18 +61,6 @@ const ZH_BUNDLE = { description: '卡片头部描述', subCaption: '本季度已赢单', }, - // A widget whose bundle entry has `description` but NO - // `subCaption` — the (b) direction. - winrate: { - title: '赢单率', - description: '只属于卡片头部', - }, - // Sub-caption only, no `description` — used by (e)'s precedence - // control is NOT this one; this one pins that a `subCaption` - // entry alone is enough. - pipeline: { - subCaption: '按阶段推进', - }, }, }, }, @@ -106,169 +69,119 @@ const ZH_BUNDLE = { }; function dashboard(...widgets: Record[]): DashboardComponentSchema { - return { - type: 'dashboard', - name: 'sales', - widgets, - } as unknown as DashboardComponentSchema; + return { type: 'dashboard', name: 'sales', widgets } as unknown as DashboardComponentSchema; } -function renderIn(language: string, schema: DashboardComponentSchema) { - return render( - - +/** Both surfaces, driven identically: an inline metric renders through each one's own metric arm. */ +const SURFACES: Array<[string, (schema: DashboardComponentSchema) => React.ReactElement]> = [ + ['DashboardRenderer', (schema) => ], + ['DashboardGridLayout', (schema) => ], +]; + +async function renderIn( + surface: (schema: DashboardComponentSchema) => React.ReactElement, + schema: DashboardComponentSchema, + value: string, +) { + const view = render( + + {surface(schema)} , ); + // The grid mounts its widgets only after it measures its width, so settle on + // the drawn value (the lit half of every case) before asserting absence. + await screen.findByText(value); + return view; } -describe('DashboardRenderer — the KPI sub-caption translates from its OWN convention key (#4032 item 4)', () => { - it('(a) renders the bundle `subCaption` on a self-contained metric card', () => { - renderIn('zh', dashboard({ - id: 'revenue', +describe.each(SURFACES)('%s — an inline metric card draws no sub-caption from `options` (objectui#11389)', (_name, surface) => { + it('an authored plain-string `options.description` draws nothing', async () => { + await renderIn(surface, dashboard({ + id: 'untranslated', type: 'metric', - title: 'Total Revenue', - options: { value: 1930000, description: 'Won this quarter' }, - })); + title: 'Bookings', + options: { value: '12 deals', description: 'Signed this week' }, + }), '12 deals'); - expect(screen.getByText('本季度已赢单')).toBeTruthy(); - // The authored English must not survive beside the translation. - expect(screen.queryByText('Won this quarter')).toBeNull(); + expect(screen.getByText('Bookings')).toBeTruthy(); + expect(screen.queryByText('Signed this week')).toBeNull(); }); - it('(a2) resolves a `subCaption`-only bundle entry (no `description` sibling)', () => { - renderIn('zh', dashboard({ - id: 'pipeline', + it('an authored per-locale map on `options.description` draws nothing, in any language', async () => { + await renderIn(surface, dashboard({ + id: 'untranslated', type: 'metric', - title: 'Pipeline', - options: { value: 42, description: 'Advancing by stage' }, - })); + title: 'Bookings', + options: { value: '12 deals', description: { en: 'Signed this week', 'zh-CN': '本周已签' } }, + }), '12 deals'); - expect(screen.getByText('按阶段推进')).toBeTruthy(); - expect(screen.queryByText('Advancing by stage')).toBeNull(); + expect(screen.queryByText('本周已签')).toBeNull(); + expect(screen.queryByText('Signed this week')).toBeNull(); }); +}); - it('(b) SEPARATION — the `description` key never reaches `options.description`', () => { - // `winrate` has a `description` entry and NO `subCaption`. The sub-caption - // must stay the authored English: the card header's translation is not a - // substitute for the sub-caption's, and borrowing it is exactly the shared - // key the item-4 ruling forbids. - renderIn('zh', dashboard({ - id: 'winrate', +describe('DashboardRenderer — the bundle `subCaption` limb is gone too (objectui#11389)', () => { + it('a bundle `subCaption` entry draws nothing, while the same entry\'s `title` still translates', async () => { + await renderIn(SURFACES[0]![1], dashboard({ + id: 'revenue', type: 'metric', - title: 'Win Rate', - options: { value: '42%', description: 'Closed-won share' }, - })); + title: 'Total Revenue', + options: { value: '1.93M', description: 'Won this quarter' }, + }), '1.93M'); - expect(screen.getByText('Closed-won share')).toBeTruthy(); - expect(screen.queryByText('只属于卡片头部')).toBeNull(); + // Lit control: the bundle lookup is live on this widget. + expect(screen.getByText('总收入')).toBeTruthy(); + expect(screen.queryByText('本季度已赢单')).toBeNull(); + expect(screen.queryByText('Won this quarter')).toBeNull(); }); - it('(c) SEPARATION — one widget, both keys, two destinations', () => { - // `kpi` is metric-family but NOT self-contained, so it takes the shared - // Card header (fed by `widget.description` → the `description` key) AND - // renders a metric card inside it (fed by `options.description` → the - // `subCaption` key). Both are visible at once, which is the only place the - // two-fields-two-keys contract can be observed end to end in the renderer. - renderIn('zh', dashboard({ + it('`widget.description` still draws as the card-header subtitle; `options.description` beside it does not', async () => { + // `kpi` is metric-family but not self-contained, so it takes the shared + // card header: title + `widget.description`, translated through the widget + // node's `description` key. The metric inside it no longer draws a caption. + await renderIn(SURFACES[0]![1], dashboard({ id: 'revenue', type: 'kpi', title: 'Total Revenue', - description: 'Card header caption', - options: { value: 1930000, description: 'Won this quarter' }, - })); + description: 'Card header subtitle', + options: { value: '1.93M', description: 'Won this quarter' }, + }), '1.93M'); - // Header half — already green since #4358. expect(screen.getByText('卡片头部描述')).toBeTruthy(); - expect(screen.queryByText('Card header caption')).toBeNull(); - // Sub-caption half — this card. - expect(screen.getByText('本季度已赢单')).toBeTruthy(); + expect(screen.queryByText('Card header subtitle')).toBeNull(); + expect(screen.queryByText('本季度已赢单')).toBeNull(); expect(screen.queryByText('Won this quarter')).toBeNull(); - // And they are DISTINCT strings in distinct nodes: a collapse onto one key - // would render one of them twice and drop the other. - expect(screen.getAllByText('卡片头部描述')).toHaveLength(1); - expect(screen.getAllByText('本季度已赢单')).toHaveLength(1); - }); - - it('(d) leaves a metric with no `subCaption` entry exactly as authored', () => { - renderIn('zh', dashboard({ - id: 'untranslated', - type: 'metric', - title: 'Bookings', - options: { value: 12, description: 'Signed this week' }, - })); - - expect(screen.getByText('Signed this week')).toBeTruthy(); }); - it('(d2) grows no caption row when nothing authored one and nothing translates it', () => { - // The resolver must answer `undefined`, not `''`: `MetricWidget` gates the - // whole caption row on `(trend || description)`, so an empty string is the - // difference between "no row" and "an empty row with the muted styling". - const { container } = renderIn('zh', dashboard({ + it('an untranslated `widget.description` draws as the header subtitle verbatim (control)', async () => { + await renderIn(SURFACES[0]![1], dashboard({ id: 'untranslated', - type: 'metric', - title: 'Win Rate', - options: { value: '42%' }, - })); - - expect(container.textContent).toBe('Win Rate42%'); - }); - - it('(e) still collapses an inline per-locale map on `options.description`', () => { - // The #4208 channel `MetricWidget` already owns. It must survive: the fix - // composes the two channels, it does not replace this one. - renderIn('zh', dashboard({ - id: 'untranslated', - type: 'metric', + type: 'kpi', title: 'Bookings', - options: { - value: 12, - description: { en: 'Signed this week', 'zh-CN': '本周已签' }, - }, - })); + description: 'Card header subtitle', + options: { value: '12 deals', description: 'Signed this week' }, + }), '12 deals'); - expect(screen.getByText('本周已签')).toBeTruthy(); + expect(screen.getByText('Card header subtitle')).toBeTruthy(); expect(screen.queryByText('Signed this week')).toBeNull(); }); - it('(f) prefers the bundle `subCaption` over an inline per-locale map', () => { - // Same fixed composition order `tWidgetTitle` applies: the authored value - // is collapsed to the active language FIRST and handed to the bundle as its - // fallback, so a bundle entry always wins. - renderIn('zh', dashboard({ - id: 'revenue', - type: 'metric', - title: 'Total Revenue', - options: { - value: 1930000, - description: { en: 'Won this quarter', 'zh-CN': '内联副标题' }, - }, - })); - - expect(screen.getByText('本季度已赢单')).toBeTruthy(); - expect(screen.queryByText('内联副标题')).toBeNull(); - }); - - it('(g) falls back to the authored sub-caption when the dashboard has no name', () => { - // No `name` → no convention key to build. The authored value is all there - // is, and it must still render. + it('a dashboard with no name draws no authored sub-caption either', async () => { + // No `name` meant no bundle key, and the authored value used to answer + // alone. There is no authored channel left to answer. render( , ); - expect(screen.getByText('Won this quarter')).toBeTruthy(); - expect(screen.queryByText('本季度已赢单')).toBeNull(); + await screen.findByText('1.93M'); + expect(screen.getByText('Total Revenue')).toBeTruthy(); + expect(screen.queryByText('Won this quarter')).toBeNull(); }); }); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.servedLocalized-11295.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.servedLocalized-11295.test.tsx index 89782d0ab6..f1dc7950e1 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.servedLocalized-11295.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.servedLocalized-11295.test.tsx @@ -13,8 +13,8 @@ * * A dashboard read from the server's `/meta` route is already resolved for the * request's locale: `translateDashboard` (`@objectstack/spec`) overlays the - * packaged catalog onto `label`, `description` and each widget's `title`, - * `description` and sub-caption (`options.description`), and since + * packaged catalog onto `label`, `description` and each widget's `title` and + * `description`, and since * objectstack#20680 / #20730 it keeps a published edit over that catalog (an * explicit override beats the packaged default). The renderer then ran the * client bundle over the served strings AGAIN, offering each served value as @@ -29,7 +29,7 @@ * `DashboardRenderer` cannot see where its document came from, so the host that * read it says so: `localized` is set by the console's dashboard page, which * draws a document from the `/meta` read (`DashboardView`). With it, the served - * title, description and sub-caption are drawn as given. Without it — an inline + * title and description are drawn as given. Without it — an inline * or preview document the server never translated — the bundle composition is * unchanged, and the last block below is the control for that. * @@ -42,6 +42,13 @@ * edited; * - inline, the same edited document with no `localized` → GREEN on both * sides: the bundle still wins there, which is the control. + * + * ## The retired sub-caption (objectui#11389) + * + * The widget used to carry a fifth text, its sub-caption + * (`options.description`, translated by a `subCaption` bundle entry). Ruling C + * retired it at both ends, so the fixtures keep both on the widget and in the + * bundle, and every case pins that neither draws, served or inline. */ import * as React from 'react'; @@ -72,6 +79,7 @@ const BUNDLE = { widget_total_users: { title: 'Total Users', description: 'Active accounts', + // Refused by the installed spec; kept to pin that it draws nothing. subCaption: 'Across all organizations', }, }, @@ -103,16 +111,21 @@ interface Texts { description: string; title: string; widgetDescription: string; - subCaption: string; } +/** + * The retired sub-caption: authored on the widget's `options` and translated + * by the bundle's `subCaption` entries above. None of these may draw. + */ +const RETIRED_SUB_CAPTION = 'All orgs (edited-11295)'; +const RETIRED_TEXTS = [RETIRED_SUB_CAPTION, 'Across all organizations', '覆盖所有组织']; + /** A published edit, as the server serves it in every locale. */ 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)', }; /** The unedited document, as the server serves it per locale: already translated. */ @@ -122,22 +135,20 @@ 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: '覆盖所有组织', }, }; /** * One `kpi` widget: metric-family but not self-contained, so the card header - * draws `title` + `description` AND the metric inside draws the sub-caption — - * all three widget channels on screen at once. `header` makes the renderer's - * own title and description visible too. + * draws `title` + `description` and the metric inside draws its value. Its + * `options.description` is the retired sub-caption, kept to pin its absence. + * `header` makes the renderer's own title and description visible too. */ function dashboard(texts: Texts): DashboardComponentSchema { return { @@ -152,7 +163,7 @@ function dashboard(texts: Texts): DashboardComponentSchema { type: 'kpi', title: texts.title, description: texts.widgetDescription, - options: { value: 42, description: texts.subCaption }, + options: { value: 42, description: RETIRED_SUB_CAPTION }, }, ], } as unknown as DashboardComponentSchema; @@ -171,7 +182,7 @@ function expectDrawn(texts: Texts) { for (const text of Object.values(texts)) expect(screen.getAllByText(text).length).toBeGreaterThan(0); } -function expectAbsent(texts: Texts) { +function expectAbsent(texts: Texts | readonly string[]) { for (const text of Object.values(texts)) expect(screen.queryByText(text)).toBeNull(); } @@ -182,12 +193,14 @@ describe('DashboardRenderer — a served dashboard is drawn as served (objectui# expectDrawn(EDITED); // The packaged catalog must not win a second time, client-side. expectAbsent(SERVED_UNEDITED[language]); + expectAbsent(RETIRED_TEXTS); }); it.each(['en', 'zh-CN'] as const)('%s: an unedited widget still shows the packaged translation the server put in', (language) => { renderIn(language, dashboard(SERVED_UNEDITED[language]), true); expectDrawn(SERVED_UNEDITED[language]); + expectAbsent(RETIRED_TEXTS); }); }); @@ -199,5 +212,6 @@ describe('DashboardRenderer — an inline document keeps the bundle composition expectDrawn(SERVED_UNEDITED[language]); expectAbsent(EDITED); + expectAbsent(RETIRED_TEXTS); }); }); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.datasetSubCaption.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.datasetSubCaption.test.tsx index 3680f56e3a..6b5a745245 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.datasetSubCaption.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.datasetSubCaption.test.tsx @@ -7,64 +7,39 @@ */ /** - * objectui#8889 — the sub-caption's BUNDLE limb reaches a DATASET-BOUND KPI tile. + * objectui#11389 — a DATASET-BOUND KPI tile draws no sub-caption, on either + * dashboard surface (ruling C: the metric sub-caption is retired at both ends). * - * `tWidgetSubCaption` has two limbs. objectui#7293 / PR #8887 landed limb 1 — - * the AUTHORED value, `widget.options.description`, collapsed to the active - * language — inside `DatasetWidget` itself, and with it the server-overlay - * path (`translateDashboard` writes the resolved translation ONTO - * `options.description`, so a platform-served dashboard arrives pre-translated - * and limb 1 renders it verbatim). + * This file used to pin objectui#8889: both surfaces resolved the sub-caption + * through one hook, `useWidgetSubCaption`, with two limbs (the authored + * `options.description`, and a client bundle entry at + * `dashboards..widgets..subCaption` that won over it), and handed the + * answer to `DatasetWidget`. Both limbs are retired with the hook: + * `@objectstack/spec` 17.7.0 removed the server overlay that wrote + * `options.description` and refuses a `subCaption` bundle entry by name, and + * the spec never declared the options key. The reversed pins: * - * Limb 2 — a client i18n BUNDLE entry at - * `{ns}.dashboards.{dash}.widgets.{id}.subCaption` overriding the authored - * value — could not follow it down. It needs the dashboard NAME, and neither - * dispatch site handed `DatasetWidget` anything but `widget` and `dataSource`. - * So a dashboard loaded from an APP BUNDLE whose sub-caption was written ONLY - * in the bundle rendered nothing at all — a shape `tWidgetSubCaption`'s own - * docblock calls legitimate: "A translation with no authored counterpart is - * legitimate and matches the server." + * - R1/R2 an authored `options.description` (plain string, per-locale map) + * grows no caption node on either surface; + * - R3 a bundle `subCaption` entry grows no caption node on either surface; + * - L1 lit control, both surfaces: the tile's value still draws, so every + * absence above is a reading of a drawn tile, not of an unmounted one; + * - L2 lit control, `DashboardRenderer`: `widget.description` still draws as + * the card-header subtitle over the tile (the ruling's other pin). * - * ## What this file measures, and in which direction - * - * Every case below was run against the fix and against an ABLATION of it (the - * two dispatch sites reverted to ``, - * rebuilt in place). The directions are stated first, per this repo's rule that - * "it ran, all green" is not a reading: - * - * - **S1/S2 (bundle-only sub-caption, no authored value) — RED before, green - * after**, once per surface. These are the card's named probe. - * - **S3/S4 (bundle AND authored on one tile) — RED before, green after**, - * once per surface. ⚠️ This is a SUBJECT, not a control: "a bundle entry - * always wins over an inline map" is exactly what was broken on the dataset - * path, so pre-fix the tile renders the AUTHORED English. Anything asserting - * bundle-beats-authored on a dataset tile moves with the subject by - * construction. - * - **C1 (authored only, no bundle entry) — GREEN before AND after.** This is - * the real control: it is objectui#7293's limb 1, and an implementation that - * delivered limb 2 by REPLACING limb 1 would turn it red. It stays green - * under the ablation, so it discriminates. - * - **C2 (neither channel) — GREEN before AND after.** No caption node grows; - * the resolver answers `undefined`, never `''`. - * - **C3 (no dashboard surface at all — `DatasetWidget` rendered directly) — - * GREEN before AND after.** The prop is omitted, the component resolves the - * authored limb for itself, and `__tests__/DatasetWidget.subCaption.test.tsx` - * keeps passing unchanged. Pinned here too because it is the case the new - * prop's `undefined` state exists for. - * - * S2 and S4 are the objectui#4614 half: BOTH dashboard surfaces route a - * dataset-bound widget to the same component, so a fix wired into one dispatch - * site leaves the other silently unchanged. A one-site fix passes S1/S3 and - * fails S2/S4. + * Directions, written before the run: R1, R2 and R3 are RED on the pre-change + * code (the caption node drew) and GREEN after; L1 and L2 are GREEN on both. + * Running every case on BOTH surfaces is objectui#4614's lesson: a change wired + * into one dispatch site leaves the other silently unchanged. * * No `dist/` is involved: the root `vitest.config.mts` aliases every * `@object-ui/*` specifier to that package's `src/`, and this file imports its - * subjects relatively, so an ablation of the fix reads source directly. + * subjects relatively. */ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { render, screen, cleanup } from '@testing-library/react'; import { I18nProvider } from '@object-ui/i18n'; import type { DashboardComponentSchema } from '@object-ui/types'; // Module scope, never inside a hook — AGENTS.md 测试纪律. Both surfaces render @@ -73,18 +48,15 @@ import type { DashboardComponentSchema } from '@object-ui/types'; import '@object-ui/components'; import { DashboardRenderer } from '../DashboardRenderer'; import { DashboardGridLayout } from '../DashboardGridLayout'; -import { DatasetWidget } from '../DatasetWidget'; afterEach(cleanup); /** * `crm` is discovered as an app namespace because it carries a `dashboards` - * sub-key — the same discovery every other convention lookup performs. - * - * `pipeline` carries a `subCaption` and NOTHING else: it is the card's probe, - * the "translation with no authored counterpart" shape. `revenue` carries one - * too, and the widgets below pair it with an authored `options.description` so - * the precedence direction is observable. + * sub-key. `pipeline` carries ONLY a `subCaption` (the shape objectui#8889 made + * draw); `revenue` carries one beside a live `description` entry. The installed + * spec refuses both `subCaption` entries; `I18nProvider` takes a host's + * resources without that schema, which is the door these pins close. */ const ZH_BUNDLE = { zh: { @@ -93,7 +65,7 @@ const ZH_BUNDLE = { sales: { widgets: { pipeline: { subCaption: '按阶段推进' }, - revenue: { subCaption: '本季度已赢单' }, + revenue: { description: '卡片头部描述', subCaption: '本季度已赢单' }, }, }, }, @@ -102,13 +74,13 @@ const ZH_BUNDLE = { }; /** A dataset-bound metric tile — `dataset` is what routes it to `DatasetWidget`. */ -const datasetMetric = (id: string, options?: Record) => ({ +const datasetMetric = (id: string, extras: Record = {}) => ({ id, type: 'metric', title: 'Revenue', dataset: 'sales', values: ['revenue'], - ...(options ? { options } : {}), + ...extras, }); const dashboard = (...widgets: Record[]): DashboardComponentSchema => @@ -116,11 +88,6 @@ const dashboard = (...widgets: Record[]): DashboardComponentSch const makeSource = () => ({ queryDataset: vi.fn(async () => ({ rows: [{ revenue: 510000 }] })) }); -/** - * The two surfaces, driven identically. Parameterising them is the point: the - * assertions below are written ONCE and must hold on both, which is what makes - * a one-dispatch-site fix fail rather than half-pass. - */ const SURFACES: Array<[string, (schema: DashboardComponentSchema, dataSource: unknown) => React.ReactElement]> = [ ['DashboardRenderer', (schema, dataSource) => ], ['DashboardGridLayout', (schema, dataSource) => ], @@ -130,115 +97,63 @@ const renderSurface = async ( surface: (schema: DashboardComponentSchema, dataSource: unknown) => React.ReactElement, schema: DashboardComponentSchema, ) => { - const src = makeSource(); const { container } = render( - {surface(schema, src)} + {surface(schema, makeSource())} , ); - // The grid mounts its widgets only after `useContainerWidth` reports, so wait - // for the resolved measure rather than asserting on the first paint. + // L1 on every case: the grid mounts its widgets only after it measures its + // width, so settle on the resolved measure before asserting any absence. await screen.findByText('510000'); return container; }; +/** The caption node `DatasetWidget` drew before objectui#11389. */ const captionOf = (container: HTMLElement) => container.querySelector('[data-testid="dataset-metric-subcaption"]'); -describe.each(SURFACES)('%s — a dataset-bound KPI tile resolves the sub-caption bundle limb (#8889)', (_name, surface) => { - // ── Subjects: RED before this change on BOTH surfaces. ─────────────────── - it('S1/S2 renders a bundle sub-caption the author never wrote a counterpart for', async () => { - const container = await renderSurface(surface, dashboard(datasetMetric('pipeline'))); - expect(captionOf(container)).not.toBeNull(); - expect(captionOf(container)).toHaveTextContent('按阶段推进'); - }); - - it('S3/S4 lets the bundle entry win over the authored value', async () => { +describe.each(SURFACES)('%s — a dataset-bound KPI tile draws no sub-caption (objectui#11389)', (_name, surface) => { + it('R1 an authored plain-string `options.description` grows no caption node', async () => { const container = await renderSurface( surface, - dashboard(datasetMetric('revenue', { description: 'Won this quarter' })), + dashboard(datasetMetric('untranslated', { options: { description: 'awaiting confirmation' } })), ); - expect(captionOf(container)).toHaveTextContent('本季度已赢单'); - // The authored English must not survive beside the translation — one tile, - // one sub-caption, and the bundle owns it. - expect(container.textContent).not.toContain('Won this quarter'); + expect(captionOf(container)).toBeNull(); + expect(container.textContent).not.toContain('awaiting confirmation'); }); - it('S3/S4 lets the bundle entry win over an authored inline per-locale map', async () => { - // The composition order `tWidgetTitle` fixed: the authored map is collapsed - // to the active language FIRST and offered to the bundle as its fallback, - // so the bundle still wins. A resolver that checked the bundle only when - // nothing was authored would render '本季度已赢单' here by accident and - // '本周已签' if the order were reversed. + it('R2 an authored per-locale map on `options.description` grows no caption node', async () => { const container = await renderSurface( surface, - dashboard(datasetMetric('revenue', { description: { en: 'Won this quarter', 'zh-CN': '本周已签' } })), + dashboard(datasetMetric('untranslated', { options: { description: { en: 'Signed this week', 'zh-CN': '本周已签' } } })), ); - expect(captionOf(container)).toHaveTextContent('本季度已赢单'); + expect(captionOf(container)).toBeNull(); expect(container.textContent).not.toContain('本周已签'); + expect(container.textContent).not.toContain('Signed this week'); }); - // ── Controls: GREEN before AND after. These must not move on ablation. ─── - it('C1 keeps rendering an authored sub-caption that no bundle entry translates', async () => { - // objectui#7293's limb 1. `untranslated` has no entry in ZH_BUNDLE at all, - // so limb 2 has nothing to say and the authored value must survive intact. - // An implementation that delivered limb 2 by replacing limb 1 reds here. - const container = await renderSurface( - surface, - dashboard(datasetMetric('untranslated', { description: 'awaiting confirmation' })), - ); - expect(captionOf(container)).toHaveTextContent('awaiting confirmation'); - }); - - it('C1 keeps collapsing an authored inline per-locale map with no bundle entry', async () => { - const container = await renderSurface( - surface, - dashboard(datasetMetric('untranslated', { description: { en: 'Signed this week', 'zh-CN': '本周已签' } })), - ); - expect(captionOf(container)).toHaveTextContent('本周已签'); - }); - - it('C2 grows no caption node when neither channel says anything', async () => { - // The resolver must answer `undefined`, never `''`: the caption row is - // gated on truthiness, so an empty string is the difference between "no - // node" and "an empty muted node". - const container = await renderSurface(surface, dashboard(datasetMetric('untranslated'))); + it.each([ + ['with no authored value', datasetMetric('pipeline'), ['按阶段推进']], + ['beside an authored value', datasetMetric('revenue', { options: { description: 'Won this quarter' } }), ['本季度已赢单', 'Won this quarter']], + ])('R3 a bundle `subCaption` entry grows no caption node, %s', async (_label, widget, absent) => { + const container = await renderSurface(surface, dashboard(widget)); expect(captionOf(container)).toBeNull(); + for (const text of absent) expect(container.textContent).not.toContain(text); }); }); -describe('#8889 leaves the no-surface path exactly as objectui#7293 left it', () => { - it('C3 resolves the authored limb when no dashboard surface passed a sub-caption', async () => { - // The `subCaption` prop is OMITTED here, which is the state that means - // "nobody upstream resolved this" — there is no dashboard name in - // existence, hence no bundle key. `DatasetWidget` resolves the authored - // value for itself, exactly as #7293 landed it. - const src = makeSource(); - const { container } = render( - , - ); - await screen.findByText('510000'); - expect(captionOf(container)).toHaveTextContent('awaiting confirmation'); - }); - - it('C3 renders nothing for an explicit `null` — a surface resolved it, to nothing', async () => { - // `null` is NOT the same as the prop being absent. A surface that resolved - // the sub-caption to nothing has already consulted both channels, and - // falling back to the authored value here is precisely how the dataset tile - // and the inline `getComponentSchema()` arms would start to disagree. - const src = makeSource(); - const { container } = render( - , +describe('DashboardRenderer — `widget.description` is still the header subtitle (objectui#11389)', () => { + it('L2 draws the translated `widget.description` over a dataset tile whose `options.description` draws nothing', async () => { + const container = await renderSurface( + SURFACES[0]![1], + dashboard(datasetMetric('revenue', { + description: 'Card header subtitle', + options: { description: 'Won this quarter' }, + })), ); - await screen.findByText('510000'); - await waitFor(() => expect(captionOf(container)).toBeNull()); - expect(container.textContent).not.toContain('awaiting confirmation'); + expect(screen.getByText('卡片头部描述')).toBeTruthy(); + expect(captionOf(container)).toBeNull(); + expect(container.textContent).not.toContain('Won this quarter'); + expect(container.textContent).not.toContain('本季度已赢单'); }); }); diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.subCaption.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.subCaption.test.tsx index e2b316b598..57df11871b 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.subCaption.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.subCaption.test.tsx @@ -1,48 +1,34 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectui#7293 — a dataset-bound KPI tile must render the sub-caption its - * author declared in `options.description`. + * objectui#11389 — a dataset-bound KPI tile draws no sub-caption from its + * options bag, whatever an author wrote there (ruling C: the metric sub-caption + * is retired at both ends). * - * Why it never did: the sub-caption slot is wired end to end and terminated - * nowhere. It has its own translation key - * (`{ns}.dashboards.{dash}.widgets.{id}.subCaption`, objectui#4032 item 4 / - * objectstack#8056), the server's `translateDashboard` overlays that - * translation onto `options.description`, and `DashboardRenderer`'s - * `tWidgetSubCaption` resolves it — but attaches the resolved value only to the - * two INLINE arms of `getComponentSchema()`. `dataset` is REQUIRED on - * `DashboardWidgetSchema` (re-read for this card against the PUBLISHED - * `@objectstack/spec@17.4.0`: required keys are exactly `id` / `dataset` / - * `values`), so every spec-legal widget routes to `DatasetWidget` instead, and - * `grep -n description` in that file returned 0 hits against 25 line-hits for - * `options` — a real absence, not a misread. Four layers of live plumbing, no - * consumer: the ADR-0049 declared-but-unenforced shape. + * This file used to pin objectui#7293, which taught `DatasetWidget` to draw an + * authored `options.description` as a caption row under the value. The spec + * never declared that key; the server overlay that wrote it is gone in + * `@objectstack/spec` 17.7.0, which also refuses the `subCaption` translation + * key by name. The component now has no sub-caption prop and no read of the + * key, and these are the reversed pins, rendered with `DatasetWidget` mounted + * directly (no dashboard surface, so nothing upstream can supply a caption): * - * The fix reads the key in `DatasetWidget` rather than plumbing it in as a - * prop, and that is load-bearing: BOTH dashboard surfaces route a dataset-bound - * widget to this one component (`DashboardRenderer` and `DashboardGridLayout`, - * objectui#4614), so a prop passed from one dispatch site would have fixed one - * surface and left the other silently unchanged. + * - every authored form of `options.description` (plain string, a value the + * server used to overlay, a per-locale map under either language) renders + * the no-caption markup BYTE-FOR-BYTE — RED on the pre-change code, which + * appended a caption span, and GREEN after; + * - the no-options and empty-value rows were the old file's control half and + * stay GREEN on both sides: that markup is the one every case now draws; + * - the objectui#7293 repro (three measures) still draws one value and, + * now, no caption. * - * The two halves of this file pin OPPOSITE directions on purpose: - * - * - the **no-sub-caption** markup is asserted byte-for-byte. It was green - * BEFORE this change and stays green after — it proves the caption row - * gained no empty node and no stray spacing. Reverting the change must NOT - * turn it red, which is what makes it a control rather than a second copy - * of the subject; - * - the **declared sub-caption** assertions were RED before (nothing rendered - * at all) and are green after. Those are the fix's evidence. - * - * Deliberately NOT pinned as a feature: measures after `values[0]`. That is - * suggestion 2 on the card, it would give `values[1..]` rendering semantics - * they do not have today, and it is a separate card on the manual-floor route. - * The one assertion about it here records that this change did not ride it in. + * The widget's one authored description is `widget.description`, drawn as the + * card-header subtitle by the dashboard surface, not by this component; the + * surfaces' files pin that half. * * No `dist/` is involved: the root `vitest.config.mts` aliases every * `@object-ui/*` specifier to that package's `src/`, and this file imports - * `../DatasetWidget` relatively, so an ablation of the fix reads source - * directly. + * `../DatasetWidget` relatively. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -53,11 +39,10 @@ import { DatasetWidget } from '../DatasetWidget'; afterEach(cleanup); /** - * The metric tile's markup with NO sub-caption declared, exactly as - * origin/main@64c3cdd44 renders it. Spelled out in full (not a snapshot file) - * so a regression shows up as a diff in the test source review. This is the - * same byte string `DatasetWidget.colorVariant.test.tsx` pins for the same - * widget — two files measuring the pre-change bytes independently. + * The metric tile's markup with no sub-caption, spelled out in full (not a + * snapshot file) so a regression shows up as a diff in the test source review. + * The same byte string `DatasetWidget.colorVariant.test.tsx` pins for the same + * widget. */ const BASELINE_NO_SUBCAPTION = '
' @@ -65,13 +50,6 @@ const BASELINE_NO_SUBCAPTION = + 'revenue' + '
'; -/** The baseline with the sub-caption span appended — nothing else may move. */ -const withSubCaption = (text: string) => - BASELINE_NO_SUBCAPTION.replace( - '', - `${text}`, - ); - const renderMetric = async ( widgetExtras: Record = {}, rows: Record[] = [{ revenue: 510000 }], @@ -102,81 +80,52 @@ const renderMetricIn = async (language: string, widgetExtras: Record { - // ── Control half: green BEFORE and after. Must not red on ablation. ────── - it('renders the pre-change markup byte-for-byte when no sub-caption is declared', async () => { - const container = await renderMetric(); - expect(container.innerHTML).toBe(BASELINE_NO_SUBCAPTION); - }); +const CAPTION = '[data-testid="dataset-metric-subcaption"]'; +describe('DatasetWidget metric tile — no sub-caption from the options bag (objectui#11389)', () => { + // ── Control half: GREEN before and after. ──────────────────────────────── it.each([ ['no options bag at all', undefined], ['an options bag without the key', { limit: 10 }], ['an explicitly empty string', { description: '' }], ['a null', { description: null }], ['a locale map with no usable entry', { description: {} }], - ])('injects no node for %s', async (_label, options) => { + ])('renders the no-caption markup for %s', async (_label, options) => { const container = await renderMetric(options === undefined ? {} : { options }); expect(container.innerHTML).toBe(BASELINE_NO_SUBCAPTION); - expect(container.querySelector('[data-testid="dataset-metric-subcaption"]')).toBeNull(); }); - // ── Subject half: RED before this change, green after. ─────────────────── - it('renders the sub-caption an author declared as a plain string', async () => { + // ── Reversed half: RED before this change (a caption span was appended). ── + it('renders the no-caption markup for an authored plain-string `options.description`', async () => { const container = await renderMetric({ options: { description: 'awaiting confirmation / awaiting approval' }, }); - expect(screen.getByTestId('dataset-metric-subcaption')).toHaveTextContent( - 'awaiting confirmation / awaiting approval', - ); - // …and the tile is otherwise untouched: the value, the measure label and - // the layout are the baseline bytes with exactly one span appended. - expect(container.innerHTML).toBe(withSubCaption('awaiting confirmation / awaiting approval')); + expect(container.querySelector(CAPTION)).toBeNull(); + expect(container.innerHTML).toBe(BASELINE_NO_SUBCAPTION); }); - it('renders the value the server overlaid onto the key', async () => { - // `translateDashboard` writes the resolved `widgets.{id}.subCaption` - // translation onto `options.description` before the document reaches the - // client, so on a served dashboard the plain string IS the translation. - // Nothing extra is needed for that path — it is the case above, and this - // pins that a served document keeps its overlaid text verbatim. + it('renders the no-caption markup for the text the server used to overlay onto the key', async () => { + // `translateDashboard` wrote the resolved `subCaption` translation here + // until `@objectstack/spec` 17.7.0. A stored document can still carry it. const container = await renderMetric({ id: 'list_completeness', options: { description: '待确认 7 / 待审批 3' }, }); - expect(container.innerHTML).toBe(withSubCaption('待确认 7 / 待审批 3')); + expect(container.innerHTML).toBe(BASELINE_NO_SUBCAPTION); }); - it.each([ - ['zh', '待确认 / 待审批'], - ['en', 'to confirm / to approve'], - ])('collapses an authored inline per-locale map under %s', async (language, expected) => { + it.each(['zh', 'en'])('renders the no-caption markup for an authored per-locale map under %s', async (language) => { const container = await renderMetricIn(language, { - options: { - description: { en: 'to confirm / to approve', zh: '待确认 / 待审批' }, - }, + options: { description: { en: 'to confirm / to approve', zh: '待确认 / 待审批' } }, }); - expect(screen.getByTestId('dataset-metric-subcaption')).toHaveTextContent(expected); - expect(container.innerHTML).toBe(withSubCaption(expected)); - }); - - it('reads the map through `pickLocalized`, not a private string-only test', async () => { - // objectui#4032 is what a private resolver that could not read the inline - // map already cost this vocabulary: the KPI card rendered the literal - // string "metric". A `typeof === 'string'` guard here would silently drop - // the map form and re-create THIS card's own bug class inside its fix, so - // the map must not merely "not crash" — it must resolve. - const container = await renderMetricIn('zh', { - options: { description: { en: 'English only' } }, - }); - // No `zh` entry: `pickLocalized` falls through to `en` rather than missing. - expect(container.innerHTML).toBe(withSubCaption('English only')); + expect(container.querySelector(CAPTION)).toBeNull(); + expect(container.innerHTML).toBe(BASELINE_NO_SUBCAPTION); }); }); -describe('#7293 delivers the sub-caption WITHOUT widening the value vocabulary', () => { - it("renders the card's own repro: three measures, one sub-caption", async () => { - // The duly#109 tile, verbatim from the card's repro block. +describe('the objectui#7293 repro draws one value and no caption', () => { + it('three measures and an authored `options.description`: the first measure, nothing under it', async () => { + // The duly#109 tile, verbatim from objectui#7293's repro block. const src = { queryDataset: vi.fn(async () => ({ rows: [{ approved_rate: 82, duties_to_confirm: 7, duties_to_review: 3 }], @@ -195,15 +144,10 @@ describe('#7293 delivers the sub-caption WITHOUT widening the value vocabulary', />, ); await screen.findByText('82'); - expect(screen.getByTestId('dataset-metric-subcaption')).toHaveTextContent( - 'awaiting confirmation / awaiting approval', - ); - // Suggestion 2 on the card — rendering `values[1..]` as secondary tile - // values — is NOT part of this change: it would give those entries - // rendering semantics they do not have today (a widening, hence a separate - // card on the manual-floor route). The measures after `values[0]` are still - // dropped, and this assertion is the evidence that this PR did not ride it - // in. The successor card is EXPECTED to change this expectation. + expect(container.querySelector(CAPTION)).toBeNull(); + expect(container.textContent).not.toContain('awaiting confirmation'); + // Measures after `values[0]` are still not drawn (objectui#8894 reports + // the drop); this change neither drew them nor moved that. expect(container.textContent).not.toContain('7'); expect(container.textContent).not.toContain('3'); }); diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 3c792cef6a..baae3d1488 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -322,6 +322,24 @@ declare module '@object-ui/types' { } } +/** + * A stored inline metric widget's `options`, without the retired sub-caption + * key `description` (objectui#11389, ruling C). + * + * Both surfaces' metric arms spread a dataset-less widget's `options` onto the + * {@link DashboardMetricNodeSchema} node, and `MetricWidget` draws the node's + * `description` as the line under the value. So the spread alone would keep + * drawing a stored `options.description` as a sub-caption after every read that + * named it was removed. Both arms drop it here, so the two surfaces cannot + * disagree about it. The node's own `description` member stays: it is + * `MetricWidget`'s prop, which a node sets directly, not a key of a dashboard + * widget's options bag. + */ +export function withoutRetiredSubCaption>(options: T): Omit { + const { description: _retiredSubCaption, ...rest } = options; + return rest; +} + /** * `node` with its `type` moved onto the namespaced key {@link * DASHBOARD_NODE_TYPES} names, or `node` itself when the type has no row. diff --git a/packages/plugin-dashboard/src/widgetSubCaption.ts b/packages/plugin-dashboard/src/widgetSubCaption.ts deleted file mode 100644 index c2e1a66f5d..0000000000 --- a/packages/plugin-dashboard/src/widgetSubCaption.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 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. - */ - -/** - * The metric tile's SUB-CAPTION — resolved in ONE place, for every dashboard - * surface (objectui#8889). - * - * ## What this module is - * - * The sub-caption has two channels, and this hook is the only thing in the repo - * that composes them: - * - * 1. the AUTHORED value, `widget.options.description`, read as a plain - * string or as an inline per-locale map — collapsed to the active UI - * language through the objectui#4208 `pickLocalized` seam. ⚠️ The spec - * does not DECLARE it: `DashboardWidgetOptionsSchema` has no - * `description` member and its open bag admits the key unjudged, the - * strict authoring face refuses an authored one (objectui#11228 ruling - * C), and the server's `translateDashboard` writes it on the served - * path — objectstack's `check:widget-option-census` ledgers it as an - * undeclared resolver output. Its status is an open decision on - * objectui#11070; - * 2. the client i18n BUNDLE entry, - * `{ns}.dashboards.{dash}.widgets.{id}.subCaption` (objectui#4032 item 4 / - * objectstack#8056, shipped in `@objectstack/spec@17.0.0`), which is - * offered the collapsed authored value as its FALLBACK and therefore wins - * whenever it exists. - * - * The composition ORDER is the one `tWidgetTitle` fixed and this hook inherits - * verbatim from the `DashboardRenderer` callback it replaces: collapse the - * authored value FIRST, hand the plain string that falls out to the bundle as - * its fallback. That is what makes the docblock's invariant true — "a bundle - * entry always wins over an inline map and the two channels can never disagree - * about what 'the authored sub-caption' is". - * - * ## Why it is a module and not a second copy - * - * An invariant of the form "these two channels can never disagree" needs ONE - * decision point to be worth anything. Before objectui#8889 the composition - * lived as a private `useCallback` inside `DashboardRenderer`, and it reached - * only the two INLINE arms of that file's `getComponentSchema()`. Both - * dashboard surfaces route a DATASET-BOUND widget to `DatasetWidget` instead - * (`DashboardRenderer` and `DashboardGridLayout`, objectui#4614), so that tile - * saw neither channel until objectui#7293 taught the component to read the - * authored one for itself. - * - * Finishing the job by teaching `DatasetWidget` to read the BUNDLE too — or by - * handing `DashboardGridLayout` its own copy of these four lines — would create - * a SECOND answer to "what is this tile's sub-caption", and two answers can - * drift apart. That is the per-block divergence class this repo keeps filing - * (objectui#8767, #8221). So the composition moved here, both surfaces call it, - * and what travels to `DatasetWidget` is the ANSWER, not the inputs. - * - * ## Why `undefined` and never `''` - * - * `MetricWidget` and `DatasetWidget` both gate the whole caption row on the - * value's truthiness, so a widget that declares no sub-caption must grow no - * node at all. `useObjectLabel().widgetSubCaption` already collapses a miss to - * `undefined`; the authored limb does the same via `|| undefined`. - * - * ## A served document skips limb 2 - * - * A dashboard served through `/meta` arrives with the server's answer already - * in `options.description`: `translateDashboard` writes the resolved - * `subCaption` there, and since objectstack#20680 keeps a published edit over - * the packaged catalog. Limb 2 used to run over that served value anyway, so a - * bundle entry won it back and a published edit drew as the packaged string. - * The host now says when its document was served (`localized`, objectui#11295) - * and limb 2 does not run for it; limb 1 still collapses an inline map. For a - * document the server never translated — inline, preview — both limbs run as - * before: this hook is the client half of the same convention, for the app - * bundles objectui loads into `I18nProvider` itself. - */ - -import { useCallback } from 'react'; -import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; - -/** - * The structural minimum this resolution reads. Deliberately not - * `DashboardWidgetSchema`: the two surfaces hold their widgets at two different - * static types (the grid holds the `DashboardWidgetSlotComponentSchema | - * DashboardWidgetSchema` union its `widgets` slot declares), and both satisfy - * this shape. `options` is `unknown` because that is what - * `DashboardWidgetSchema` declares it as. - */ -export interface SubCaptionWidget { - id?: string; - options?: unknown; -} - -/** - * Resolve a dataset/metric widget's sub-caption for the active UI language. - * - * `dashName` is the dashboard schema's `name` — the segment every convention - * key on this surface is built from. Without it (or without a widget `id`) - * there is no key to look up, so the authored limb answers alone; that is the - * same silent degradation `tWidgetTitle` / `tWidgetDescription` perform, not a - * new one. - * - * `localized` is the host's word that the document was served already - * translated (`DashboardRendererProps.localized`): the authored limb then - * answers alone, because the served value IS the translation. - * - * Provider-safe: `useObjectLabel` and `useObjectTranslation` both degrade to a - * no-instance stand-in when no `I18nProvider` is mounted (objectui#5564), which - * `DashboardGridLayout` depends on — it is a separately exported component a - * host mounts standalone (its `dashboard-grid` node key was retired by - * objectui#10859 batch 8). - */ -export function useWidgetSubCaption( - dashName: string | undefined, - localized = false, -): (widget: SubCaptionWidget) => string | undefined { - const { widgetSubCaption } = useObjectLabel(); - const { language } = useObjectTranslation(); - - return useCallback( - (widget: SubCaptionWidget): string | undefined => { - const authored = (widget.options as Record | undefined)?.description; - const fallback = pickLocalized(authored, language) || undefined; - if (localized || !dashName || !widget.id) return fallback; - return widgetSubCaption(dashName, widget.id, fallback); - }, - [localized, dashName, widgetSubCaption, language], - ); -} diff --git a/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts b/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts index 5690424774..2ddb613f93 100644 --- a/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts +++ b/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts @@ -13,11 +13,12 @@ * same source the platform's save gate parses with; * 2. the CONSUMED set, extracted from `DatasetWidget.tsx` source text — the * one component every spec-legal (dataset-bound) widget renders through. - * Since objectui#7293 that set is the declared five PLUS `description`: - * the metric branch renders the sub-caption, so the accepted set and the - * measured read set finally coincide; - * 3. the sub-caption convention read site in `DashboardRenderer.tsx`, the - * evidence for the single accepted key the spec does not declare; + * It is the declared five and nothing else: `description`, the metric + * sub-caption objectui#7293 read here, is retired at both ends + * (objectui#11389, ruling C), so the accepted set, the declared set and + * the measured read set are one set; + * 3. the ABSENCE of the retired sub-caption's readers across + * `plugin-dashboard`, with a lit control on the same scan; * 4. a repo tripwire for NEW files that start reading `widget.options`. * * ## What the instrument can and cannot see — read before trusting a verdict @@ -52,15 +53,19 @@ import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { DashboardWidgetOptionsSchema, DashboardWidgetSchema } from '@objectstack/spec/ui'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { stripComments } from '../../../../scripts/js-comment-mask.mjs'; import { CONSUMED_WIDGET_OPTION_KEYS, UNCONSUMED_WIDGET_OPTION, } from '../dashboard-widget-options.js'; /** - * The one accepted key `DashboardWidgetOptionsSchema` does not declare — the - * sub-caption convention (objectui#4032 item 4, objectstack#8056 `subCaption`). - * Named once here so leg 1 and leg 2 cannot drift apart about which key it is. + * The RETIRED metric sub-caption key (objectui#4032 item 4, objectui#7293; + * retired at both ends by objectui#11389, ruling C, after objectstack's half in + * `@objectstack/spec` 17.7.0). It was the one accepted key the spec never + * declared. Named once here so legs 1 to 3 cannot drift apart about which key + * they keep out. */ const SUBCAPTION_KEY = 'description'; @@ -79,12 +84,11 @@ const DATASET_WIDGET = join(repoRoot, 'packages/plugin-dashboard/src/DatasetWidg const DASHBOARD_RENDERER = join(repoRoot, 'packages/plugin-dashboard/src/DashboardRenderer.tsx'); const DASHBOARD_GRID_LAYOUT = join(repoRoot, 'packages/plugin-dashboard/src/DashboardGridLayout.tsx'); /** - * Where the sub-caption's authored read LIVES since objectui#8889. It used to - * sit inline in `DashboardRenderer.tsx`; it is now the single decision point - * both dashboard surfaces call, which is why leg 3 reads this file and then - * checks both surfaces still route to it. + * Where the sub-caption's two limbs lived from objectui#8889 until + * objectui#11389 deleted the module. Leg 3 pins that it stays gone. */ const WIDGET_SUB_CAPTION = join(repoRoot, 'packages/plugin-dashboard/src/widgetSubCaption.ts'); +const PLUGIN_DASHBOARD_SRC = join(repoRoot, 'packages/plugin-dashboard/src'); const declaredKeys = Object.keys(DashboardWidgetOptionsSchema.shape).sort(); @@ -103,12 +107,14 @@ describe('leg 1 — the spec side of the pin', () => { expect(declaredKeys).toEqual(['dateGranularity', 'limit', 'sortBy', 'sortOrder', 'stageOrder']); }); - it('every declared key is accepted, and the only undeclared accepted key is `description`', () => { + it('every declared key is accepted, and no undeclared key is — not even the retired `description`', () => { for (const key of declaredKeys) expect(CONSUMED_WIDGET_OPTION_KEYS).toContain(key); const extras = CONSUMED_WIDGET_OPTION_KEYS.filter((k) => !declaredKeys.includes(k)); - // `description` is the sub-caption convention key (leg 3). Any OTHER - // undeclared entry needs its own documented read-site evidence first. - expect(extras).toEqual([SUBCAPTION_KEY]); + // `description` was the one undeclared accepted key, the metric sub-caption + // (objectui#11389 retired it). Any undeclared entry needs its own + // documented read-site evidence before it joins. + expect(extras).toEqual([]); + expect(CONSUMED_WIDGET_OPTION_KEYS).not.toContain(SUBCAPTION_KEY); }); it('`dataset` is required — the fact the census scopes itself by', () => { @@ -149,24 +155,25 @@ describe('leg 2 — the renderer side: DatasetWidget source census', () => { expect(src, 'computed access into the options bag').not.toMatch(/\boptions\[/); }); - it('the extracted read set is the declared set plus the sub-caption key', () => { + it('the extracted read set is the declared set, without the retired sub-caption key', () => { const extracted = new Set(); for (const m of src.matchAll(/\boptions\.([A-Za-z_$][\w$]*)/g)) extracted.add(m[1]!); // Instrument control: a zero here is a broken instrument, not a reading — // `limit` is known-present at a `options.limit` read site. expect(extracted.size).toBeGreaterThan(0); expect([...extracted]).toContain('limit'); - // Until objectui#7293 this equalled `declaredKeys` alone, and `description` - // was accepted on the strength of a read site in a DIFFERENT file (leg 3). - // The metric branch now reads it here too, so the sub-caption key is a - // first-class member of this census rather than an exception to it. - expect([...extracted].sort()).toEqual([...declaredKeys, SUBCAPTION_KEY].sort()); + // objectui#7293 added `description` to this set (the metric tile's + // sub-caption); objectui#11389 took it out again (ruling C). Its return here + // is a renderer reading a retired key, so it is refused by name, not only + // by the equality. + expect([...extracted]).not.toContain(SUBCAPTION_KEY); + expect([...extracted].sort()).toEqual([...declaredKeys].sort()); }); - it('every accepted key now has a read site in the file the census measures', () => { - // The fact objectui#7293 delivers, stated as its own assertion: the - // accepted set is no longer larger than what this file reads. Losing the - // sub-caption read makes THIS red rather than silently re-opening the gap. + it('every accepted key has a read site in the file the census measures', () => { + // The accepted set is no larger than what this file reads: a key accepted + // without a read site would silence the warning on metadata that renders + // nothing. const extracted = new Set(); for (const m of src.matchAll(/\boptions\.([A-Za-z_$][\w$]*)/g)) extracted.add(m[1]!); expect([...extracted].sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS].sort()); @@ -184,29 +191,56 @@ describe('leg 2 — the renderer side: DatasetWidget source census', () => { }); }); -describe('leg 3 — the sub-caption convention read site', () => { - it('the subCaption channel still reads options.description, and both surfaces route to it', () => { - // The evidence for the one accepted key the spec does not declare - // (objectui#4032 item 4; objectstack#8056 `subCaption`; the server's - // `translateDashboard` writes this key). If this read disappears, - // `description` needs re-triage, not silent retention. - // - // objectui#8889 MOVED the read, verbatim, out of `DashboardRenderer.tsx` - // and into `widgetSubCaption.ts`: the bundle limb had to reach the - // dataset-bound tile too, and an invariant of the form "these two channels - // can never disagree" needs ONE decision point, so both dashboard surfaces - // now call the same hook instead of each composing the value. The read did - // not disappear and this leg's subject did not change — only its address. - const src = readFileSync(WIDGET_SUB_CAPTION, 'utf8'); - expect(src).toMatch(/\(widget\.options as [^)]*\)\?\.description/); +describe('leg 3 — the retired sub-caption has no reader left (objectui#11389)', () => { + /** + * Every non-test TS/TSX source file of `plugin-dashboard`, comments stripped + * through the repo's one comment scanner (`scripts/js-comment-mask.mjs`). + * `stripComments`, not `maskComments`: this leg reports file names only, + * never a line or an offset. + */ + const sources = (): Array<[string, string]> => { + const out: Array<[string, string]> = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '__tests__' || entry.name === 'node_modules') continue; + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.|\.d\.ts$/.test(entry.name)) { + // Comments may NAME the retired key (they explain its retirement); a + // read is code, so comments are dropped before matching. + const code = stripComments(readFileSync(p, 'utf8')) as string; + out.push([relative(repoRoot, p).replace(/\\/g, '/'), code]); + } + } + }; + walk(PLUGIN_DASHBOARD_SRC); + return out; + }; + + it('no source reads `options.description` or resolves a `subCaption`, and the scan is lit', () => { + const files = sources(); + // Lit control: the same comment-stripped scan still finds a live read of + // the bag, so an empty answer below is a reading, not a dead instrument. + const control = files.filter(([, code]) => /\boptions\??\.limit\b/.test(code)).map(([f]) => f); + expect(control).toContain('packages/plugin-dashboard/src/DatasetWidget.tsx'); + + const readers = files + .filter(([, code]) => + /\boptions\??\.description\b/.test(code) || + /\)\??\.description\b/.test(code) && /widget\??\.options\s+as\b/.test(code) || + /\bsubCaption\b/.test(code) || + /\bwidgetSubCaption\b/.test(code)) + .map(([f]) => f); + expect(readers).toEqual([]); + }); - // ⚠️ Re-pointing the path ALONE would be weaker than what this leg held - // before the move: it would stay green with the read stranded in a module - // nothing calls. So the reachability half is stated explicitly, and it is - // stated for BOTH surfaces — objectui#4614 is the card that exists because - // a one-surface wiring looks complete and is not. + it('the resolver module is gone and no surface calls its hook', () => { + expect(existsSync(WIDGET_SUB_CAPTION)).toBe(false); for (const surface of [DASHBOARD_RENDERER, DASHBOARD_GRID_LAYOUT]) { - expect(readFileSync(surface, 'utf8')).toMatch(/useWidgetSubCaption\(/); + const src = readFileSync(surface, 'utf8'); + expect(src).not.toMatch(/useWidgetSubCaption\(/); + // Control on the same read: the surface is the file it claims to be. + expect(src).toMatch(/ { // itself (whose header describes the bag in prose — it never renders one). // A new entry means a new consumer of the bag: re-run the census (module // header) before extending either this list or the accepted-key set. - // `useObjectLabel.ts` matches in prose only — it documents the subCaption - // convention leg 3 pins. // - // `widgetSubCaption.ts` joined on objectui#8889, and it is NOT a prose-only - // match: it carries the authored read itself, - // `(widget.options as …)?.description`, moved verbatim out of - // `DashboardRenderer.tsx` so that both dashboard surfaces resolve the - // sub-caption through one decision point. It is a first-class consumer of - // the bag under exactly the receiver spelling this tripwire watches, so it - // belongs here — the tripwire fired correctly, and the census was re-run - // rather than the number made to match. The accepted key set is UNCHANGED - // by that move: the file reads `description` and nothing else, the key was - // already accepted (leg 1), and leg 2's DatasetWidget read set still - // measures the same six. + // Two entries left with objectui#11389 (the sub-caption retired at both + // ends): `widgetSubCaption.ts`, deleted with the resolver that read + // `description` off the bag, and `useObjectLabel.ts`, whose only match was + // the prose of the retired `widgetSubCaption` member. The list shrank + // because the consumers left, not because the number was made to match. expect(hits.sort()).toEqual([ - 'packages/i18n/src/useObjectLabel.ts', 'packages/plugin-dashboard/src/DashboardGridLayout.tsx', 'packages/plugin-dashboard/src/DashboardRenderer.tsx', 'packages/plugin-dashboard/src/DatasetWidget.tsx', - 'packages/plugin-dashboard/src/widgetSubCaption.ts', 'packages/sdui-parser/src/dashboard-widget-options.ts', ]); }); diff --git a/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts b/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts index 4734e2b5e1..cf3114070a 100644 --- a/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts +++ b/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts @@ -203,3 +203,29 @@ describe('what draws NOTHING — every accepted key, and every out-of-scope shap expect(d.filter((x) => x.code === UNCONSUMED_WIDGET_OPTION)).toEqual([]); }); }); + +describe('the retired metric sub-caption draws the warning (objectui#11389, ruling C)', () => { + // `options.description` was the metric tile's sub-caption, accepted on its + // read sites. Both ends retired it (objectstack first, `@objectstack/spec` + // 17.7.0; then every reader in `plugin-dashboard`), so it reaches no renderer + // and is reported like any other unconsumed key. The verdict before this + // change was no diagnostic at all; it is now one warning, never an error. + const metric = { id: 'won_revenue', type: 'metric', dataset: 'sales', values: ['revenue'] }; + + it.each([ + ['a plain string', 'Won this quarter'], + ['an inline per-locale map', { en: 'Won this quarter', 'zh-CN': '本季度已赢单' }], + ])('an authored options.description as %s draws one unconsumed-widget-option warning', (_label, description) => { + const found = unconsumed(dash({ ...metric, options: { description } })); + expect(found).toHaveLength(1); + expect(found[0]!.severity).toBe('warning'); + expect(found[0]!.code).toBe(UNCONSUMED_WIDGET_OPTION); + // The subject is named: the key, and the widget it sits on. + expect(found[0]!.message).toContain('options.description'); + expect(found[0]!.message).toContain('"won_revenue"'); + }); + + it('control: the widget-level `description` (the card-header subtitle) and a declared option draw nothing', () => { + expect(unconsumed(dash({ ...metric, description: 'Card header subtitle', options: { limit: 5 } }))).toEqual([]); + }); +}); diff --git a/packages/sdui-parser/src/dashboard-widget-options.ts b/packages/sdui-parser/src/dashboard-widget-options.ts index d98396848f..560084ed6d 100644 --- a/packages/sdui-parser/src/dashboard-widget-options.ts +++ b/packages/sdui-parser/src/dashboard-widget-options.ts @@ -26,23 +26,18 @@ * dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588) * stageOrder (funnel/pyramid stage order) * - * plus ONE undeclared key with a real read site: - * - * description — the metric-card sub-caption channel. Read at - * `widgetSubCaption.ts` (`(widget.options as …)?.description`, objectui#4032 - * item 4 — that read sat inline in `DashboardRenderer.tsx` until - * objectui#8889 moved it, verbatim, into the hook BOTH dashboard surfaces - * now call, so the authored and bundle channels compose at one decision - * point) and, since objectui#7293, at `DatasetWidget.tsx`'s - * metric branch, which renders it in the caption row; the server's - * `translateDashboard` OVERLAYS the `widgets.{id}.subCaption` translation - * onto this key (objectstack#8056, objectstack#5428 item-4: 「两个作者字段两个 - * key」). It entered the accepted set on the translation-pipeline evidence - * alone — warning on a key the platform's own pipeline writes would be a - * false positive on legal metadata — while the dataset-bound render path - * displayed it nowhere. #7293 closed that gap, so `description` is now - * accepted for the same reason as the declared five and the accepted set no - * longer outruns the measured read set. + * and nothing else. ⛔ Not `description`: it was the metric-card sub-caption + * (objectui#4032 item 4, objectui#7293), read by `DatasetWidget.tsx`'s metric + * branch and a resolver both dashboard surfaces called, and fed by the + * server's `translateDashboard` overlay of the `widgets.{id}.subCaption` + * translation. The spec never declared it. objectui#11389 (ruling C, which + * reverses objectstack#5428 item 4) retires it at both ends: objectstack + * first, in `@objectstack/spec` 17.7.0 (the overlay is gone and the + * translation key is a tombstone), then the readers here. So an authored + * `options.description` reaches no renderer and draws this warning like any + * other unconsumed key. A widget keeps one authored description, + * `widget.description`, the card-header subtitle, which is not an `options` + * key and is not judged here. * * Notably NOT consumed on the path a widget really renders through: * `thresholds` and `format`. Both were widely believed to work; both draw this @@ -70,10 +65,9 @@ * legitimate code gets deleted by the next person who hits it, which puts * the claim back where it started. Leg 2 of * `__tests__/dashboard-widget-options-census.test.ts` derives exactly this - * bound: the DatasetWidget read set is the declared set plus the - * sub-caption key, and neither `format` nor `thresholds` is in it. Since - * objectui#7293 that absence is asserted in its OWN right rather than - * riding on an equality whose right-hand side can grow. + * bound: the DatasetWidget read set is the declared set, and neither + * `format` nor `thresholds` is in it. That absence is asserted in its OWN + * right rather than riding on an equality whose right-hand side can grow. * * ## Scope — where the warning deliberately does NOT fire * @@ -99,7 +93,8 @@ * `@objectstack/spec`, re-extracts the `options.` reads from * `DatasetWidget.tsx` source text (and fails loudly if that file gains a * consumption shape the extractor cannot see — a spread, a destructuring, a - * computed access), re-checks the sub-caption read site, and trips on any NEW + * computed access), re-checks that the retired sub-caption key has no read + * site left in `plugin-dashboard`, and trips on any NEW * file in `packages/*\/src` or `apps/*\/src` that starts reading * `widget.options`. A renderer change that adds or removes a consumed key * fails that test until this list is updated — the cost of keeping this @@ -122,12 +117,12 @@ export const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet = new Set([ /** * The accepted set: every `options` key with a renderer read site on the - * dataset-bound path, plus the sub-caption convention key. Alphabetical; the - * warning message prints it verbatim. Derivation and evidence: file header. + * dataset-bound path, which is exactly the set the spec declares. + * Alphabetical; the warning message prints it verbatim. Derivation and + * evidence: file header. */ export const CONSUMED_WIDGET_OPTION_KEYS: readonly string[] = [ 'dateGranularity', - 'description', 'limit', 'sortBy', 'sortOrder',