Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/widget-translation-subcaption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
---

feat(spec): the dashboard widget translation node gains an optional third member, `subCaption`, keyed `dashboards.<name>.widgets.<widgetId>.subCaption` (#7862)

The metric widget draws two authored strings under its header: `widget.description` (the copy under the card header) and `options.description` (the sub-caption under the number). The #5428 ruling (2026-08-06, item 4) gives each its own translation key — sharing `widget.description`'s key is forbidden (「两个作者字段两个 key」) — but the strict `{ title?, description? }` widget node refused every spelling of a second key, and its own error hint pointed `subtitle` at `description`, i.e. at the shared key the ruling forbids.

- `dashboards.<name>.widgets.<widgetId>.subCaption` is now accepted and translates the metric sub-caption (`options.description`); `description` keeps translating `widget.description` and never reaches the options bag.
- `translateDashboard` resolves the new key, overlaying `options.description` and carrying every other `options` key through untouched, on the same REST `/meta` path that already resolves widget `title`/`description`.
- The `subtitle` hint now points at `subCaption`. Unknown keys on the node are still refused.

The node stays strict; existing bundles are unaffected (the accept-set only grows). objectui's client-side renderer half consumes the same key path as a follow-up (objectui#4032 / objectui#4358).
2 changes: 1 addition & 1 deletion packages/spec/liveness/translation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,7 @@
"status": "live",
"verifiedAt": "2026-08-01",
"evidence": "packages/spec/src/system/i18n-resolver.ts:538, :554",
"note": "translateDashboard: label/description plus per-widget title/description by widget id; header action labels."
"note": "translateDashboard: label/description plus per-widget title/description/subCaption by widget id; header action labels. `subCaption` (#7862, #5428 item 4) overlays the metric widget's `options.description` — a different authored field from `widget.description`, each on its own key — live through the same translateDashboard REST path; objectui's client-side renderer half is the downstream follow-up."
},
"pages": {
"status": "live",
Expand Down
68 changes: 68 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -730,6 +730,7 @@ import {
translateApp,
translateDashboard,
resolveViewLabel as _resolveViewLabel,
type DashboardLike,
} from './i18n-resolver';

describe('locale fallback resolution (BCP-47)', () => {
Expand DownExpand Up@@ -871,6 +872,73 @@ describe('translateDashboard', () => {
expect(out.widgets[1].title).toBe('Other');
});

// #7862 — `subCaption` overlays the metric widget's `options.description`,
// a DIFFERENT authored field from `widget.description` (#5428 item 4: two
// authored fields, two keys).
describe('widget subCaption (#7862)', () => {
const subBundle: TranslationBundle = {
'zh-CN': {
dashboards: {
system_overview: {
widgets: {
widget_total_users: {
title: '用户总数',
description: '系统中注册的用户总数',
subCaption: '较上月',
},
},
},
},
},
};
const metricDashboard = {
name: 'system_overview',
widgets: [
{
id: 'widget_total_users',
type: 'metric',
title: 'Total Users',
description: 'Total registered users',
options: { description: 'vs last month', sortBy: 'created' },
},
{
id: 'widget_other',
type: 'metric',
title: 'Other',
description: 'Other card copy',
options: { description: 'untouched extra' },
},
],
};

it('overlays `options.description` and carries the other options keys through', () => {
const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' });
expect(out.widgets[0].options).toEqual({ description: '较上月', sortBy: 'created' });
});

it('keeps the two authored fields on their own keys — `description` never reaches `options.description`, `subCaption` never reaches `widget.description`', () => {
const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' });
expect(out.widgets[0].description).toBe('系统中注册的用户总数');
expect(out.widgets[0].options?.description).toBe('较上月');
});

it('leaves `options` of a widget without a subCaption entry untouched (same reference semantics as title)', () => {
const out = translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' });
expect(out.widgets[1].options).toEqual({ description: 'untouched extra' });
});

it('does not mutate the input document', () => {
translateDashboard(metricDashboard, subBundle, { locale: 'zh-CN' });
expect(metricDashboard.widgets[0].options.description).toBe('vs last month');
});

it('creates the options bag when the bundle carries a subCaption and the widget has none — mirroring how a bundle-only `title` renders', () => {
const bare: DashboardLike = { name: 'system_overview', widgets: [{ id: 'widget_total_users' }] };
const out = translateDashboard(bare, subBundle, { locale: 'zh-CN' });
expect(out.widgets?.[0]?.options).toEqual({ description: '较上月' });
});
});

it('works through translateMetadataDocument with dashboard type', () => {
const out = translateMetadataDocument('dashboard', dashboard, bundle, { locale: 'zh-CN' });
expect(out.label).toBe('系统概览');
Expand Down
22 changes: 19 additions & 3 deletions packages/spec/src/system/i18n-resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -687,6 +687,13 @@ export interface WidgetLike {
id?: string;
title?: string;
description?: string;
/**
* The renderer-extras bag (`DashboardWidgetOptionsSchema`). `translateDashboard`
* writes exactly one key into it — `description`, the metric widget's
* sub-caption slot — when the bundle carries a
* `dashboards.<name>.widgets.<id>.subCaption` entry (#5428 item 4, #7862).
*/
options?: Record<string, any>;
[key: string]: any;
}

Expand DownExpand Up@@ -717,7 +724,7 @@ function lookupWidgetAttr(
bundle: TranslationBundle | undefined,
dashboardName: string,
widgetId: string,
attr: 'title' | 'description',
attr: 'title' | 'description' | 'subCaption',
opts?: ResolveOptions,
): string | undefined {
if (!bundle) return undefined;
Expand All@@ -732,8 +739,15 @@ function lookupWidgetAttr(
/**
* Apply the active locale to a dashboard metadata document — translates the
* dashboard's `label` / `description` and each widget's `title` /
* `description` against `dashboards.<name>.widgets.<id>.*`. The input document
* is not mutated.
* `description` / `subCaption` against `dashboards.<name>.widgets.<id>.*`.
* The input document is not mutated.
*
* `subCaption` overlays the widget's `options.description` — the metric
* widget's sub-caption, a DIFFERENT authored field from `widget.description`
* (#5428 item 4: two authored fields, two keys; #7862). The `description`
* key never reaches `options.description`, and `subCaption` never reaches
* `widget.description`; the other `options` keys are carried through
* untouched.
*/
export function translateDashboard<T extends DashboardLike>(
doc: T,
Expand All@@ -755,6 +769,8 @@ export function translateDashboard<T extends DashboardLike>(
if (title) next.title = title;
const desc = lookupWidgetAttr(bundle, name, w.id, 'description', opts);
if (desc) next.description = desc;
const subCaption = lookupWidgetAttr(bundle, name, w.id, 'subCaption', opts);
if (subCaption) next.options = { ...w.options, description: subCaption };
return next;
})
: doc.widgets;
Expand Down
55 changes: 54 additions & 1 deletion packages/spec/src/system/translation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -894,6 +894,59 @@ describe('translation unknown-key strictness (#4001)', () => {
});
});

// ──────────────────────────────────────────────────────────────────────────
// #7862 — `dashboards.<name>.widgets.<id>.subCaption`, the metric widget's
// sub-caption (`options.description`). #5428 item 4: two authored fields,
// two keys — `description` translates `widget.description`, `subCaption`
// translates `widget.options.description`; sharing one key is forbidden.
// ──────────────────────────────────────────────────────────────────────────
describe('dashboard widget sub-caption (#7862)', () => {
const parse = (widgets: unknown) =>
TranslationDataSchema.safeParse({ dashboards: { sales: { widgets } } });

it('accepts `subCaption` on the widget node, alongside title/description', () => {
const result = parse({
rev: { title: '营收', description: '本季度确认的营收', subCaption: '较上季度' },
});
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});

it('keeps `title`/`description` byte-identical through the parse', () => {
const body = { rev: { title: 'Revenue', description: 'Recognized revenue this quarter' } };
const result = parse(body);
expect(result.success).toBe(true);
expect((result as { success: true; data: any }).data.dashboards.sales.widgets)
.toEqual(body);
});

it('stays `.strict()` — an invented key is still refused, with substance', () => {
const result = parse({ rev: { footnote: 'x' } });
expect(result.success).toBe(false);
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? '';
expect(message).toContain('this widget translation');
expect(message).toContain('`footnote`');
});

it('sends `subtitle` to `subCaption`, not to `description`', () => {
// On a metric widget the string an author calls the "subtitle" is the
// sub-caption under the number (`options.description`). Pointing it at
// `description` would steer authors to precisely the shared key the
// #5428 ruling forbids (「两个作者字段两个 key」).
const result = parse({ rev: { subtitle: '较上季度' } });
expect(result.success).toBe(false);
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? '';
expect(message).toContain('`subtitle` → `subCaption`');
expect(message).not.toContain('`subtitle` → `description`');
});

it('suggests `subCaption` for a near-miss spelling', () => {
const result = parse({ rev: { subCaptoin: '较上季度' } });
expect(result.success).toBe(false);
expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message)
.toContain('→ `subCaption`');
});
});

// ──────────────────────────────────────────────────────────────────────────
// #7646 — `flows.<name>.screens.<nodeId>`, the screen-flow wizard's copy
// ──────────────────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -1087,7 +1140,7 @@ describe('translation unknown-key strictness (#4001)', () => {
apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } },
messages: { 'common.save': 'Save' },
globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } },
dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue' } } } },
dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', subCaption: 'vs last quarter' } } } },
pages: { home: { label: 'Home', title: 'Welcome' } },
flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } },
settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } },
Expand Down
22 changes: 21 additions & 1 deletion packages/spec/src/system/translation.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -467,6 +467,7 @@ const translationDataShape = () => ({
* dashboards.<name>.actions.<actionUrl>.label
* dashboards.<name>.widgets.<widgetId>.title
* dashboards.<name>.widgets.<widgetId>.description
* dashboards.<name>.widgets.<widgetId>.subCaption
*/
dashboards: z.record(z.string(), strictObject({
surface: 'this dashboard translation',
Expand All@@ -488,10 +489,29 @@ const translationDataShape = () => ({
// A widget's headline is `title`; a dashboard's is `label`. Same document,
// one level apart, opposite spellings — so `label` on a widget is the
// likeliest mistake on this surface and the least likely to be noticed.
aliases: { label: 'title', name: 'title', heading: 'title', subtitle: 'description' },
//
// `subtitle` points at `subCaption`, not `description`: on a metric
// widget the string an author calls the "subtitle" is the sub-caption
// under the number (`widget.options.description`), a DIFFERENT authored
// field from `widget.description` (the copy under the card header). The
// #5428 ruling (2026-08-06, item 4) gives each authored field its own
// key — 「两个作者字段两个 key」 — so steering `subtitle` authors at
// `description` would steer them at exactly the shared key the ruling
// forbids (#7862).
aliases: { label: 'title', name: 'title', heading: 'title', subtitle: 'subCaption' },
}, {
title: z.string().optional().describe('Translated widget title'),
description: z.string().optional().describe('Translated widget description'),
/**
* Overlays the metric widget's sub-caption — the authored
* `widget.options.description`, NOT `widget.description`. Two authored
* fields, two keys (#5428 item 4, #7862): `description` above translates
* `widget.description`; this key translates `options.description`.
* Resolved by `translateDashboard` (i18n-resolver.ts); objectui's
* client-side renderer half consumes the same
* `dashboards.<name>.widgets.<widgetId>.subCaption` path.
*/
subCaption: z.string().optional().describe("Translated metric sub-caption (overlays the widget's `options.description`, a different authored field from `description`)"),
})).optional().describe('Widget translations keyed by widget id'),
})).optional().describe('Dashboard translations keyed by dashboard name'),

Expand Down
Loading