diff --git a/.changeset/26112-26142-block-inline-title-translations.md b/.changeset/26112-26142-block-inline-title-translations.md new file mode 100644 index 00000000000..6ec3859b152 --- /dev/null +++ b/.changeset/26112-26142-block-inline-title-translations.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(spec): a joined report's block headings and a master-detail field's inline grid heading are translatable + +Clause-②: yes +Metadata-protocol: added + +Two authored, user-visible labels had no translation slot, so a zh-CN user read them in the authoring language and `os i18n check` could not see the gap: + +- **A joined report's blocks.** The `reports` translation group declares `blocks`, keyed by the block's `name`: `reports..blocks..label` / `.description`. `translateReport` overlays them on each block of the served report (`GET /meta/report`), which is what the joined renderer draws above each block. +- **A field's `inlineTitle`.** `FieldTranslationSchema` declares `inlineTitle`: `objects..fields..inlineTitle`, on the child object whose master-detail (or lookup) field declares the heading. `translateObject` overlays it on the served child object (`GET /meta/object`), which is what the console copies onto the parent's form as the inline grid's heading. + +Both follow the packaged-base rule (ADR-0029 D9.2a): a string an org re-authored on top of the packaged declaration keeps its text. An authored `inlineTitle` with no entry keeps its authored text; the child object's plural label is never substituted for it. + +`os i18n extract` scaffolds both keys and `os i18n check` counts them: a block's `label` / `description` under the report bucket, `inlineTitle` under the field bucket. A block without a `label`, or a field without an `inlineTitle`, is recorded without being demanded. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 551a6319339..fd5d355ab29 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1778,7 +1778,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d16708d3bf6..a28db115d72 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -66,6 +66,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | --- @@ -90,7 +91,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -107,6 +108,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | ### Nested Shape: `ObjectTranslationData._views[string]` @@ -177,7 +179,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -195,7 +197,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -254,6 +256,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `PlatformTranslationData.pages[string]` @@ -334,7 +337,7 @@ Served translation data — the platform groups the browser reads, without the s | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -351,7 +354,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -410,6 +413,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `ServedTranslationData.pages[string]` @@ -552,7 +556,7 @@ Per-app translation data for objects, apps, and UI messages | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -569,7 +573,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -628,6 +632,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationData.pages[string]` @@ -729,7 +734,7 @@ One locale of translations — the `translation` metadata type | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -756,7 +761,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -815,6 +820,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationItem.pages[string]` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 9c535ae34bf..eb3d3289225 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 383 | +| `system/` | 384 | diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index f11cf234fd4..38af04a306b 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -92,6 +92,13 @@ export const CrmTranslationBundle = defineTranslationBundle({ 'zh-CN': { objects: { + // The inline product grid's heading on the opportunity form. The field + // lives on the line item, so the key is the line item's. + crm_opportunity_line_item: { + fields: { + opportunity: { inlineTitle: '产品明细' }, + }, + }, crm_account: { label: '客户', pluralLabel: '客户列表', diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 09cce8f102e..b00559fb8f3 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -782,6 +782,9 @@ export const ShowcaseTranslationBundle = { showcase_expense_line: { fields: { incurred_at: { label: '发生时间' }, + // The inline grid heading on the expense report's form — a key born + // with its slot, so it is translated at birth like the label above. + expense_report: { inlineTitle: '费用明细' }, }, }, // See the `en` side for why this entry translates exactly ONE field and @@ -789,6 +792,8 @@ export const ShowcaseTranslationBundle = { showcase_invoice_line: { fields: { service_start: { label: '服务开始时间' }, + // The inline grid heading on the invoice form, translated at birth. + invoice: { inlineTitle: '发票明细' }, }, }, showcase_preference: { @@ -1349,7 +1354,15 @@ export const ShowcaseTranslationBundle = { reports: { showcase_hours_by_status: { label: '按状态统计工时(汇总)', description: '按任务状态分组的预计工时。' }, showcase_status_priority_matrix: { label: '状态 × 优先级(矩阵)', description: '按状态和优先级交叉统计的预计工时。' }, - showcase_task_overview: { label: '任务概览(联合)', description: '将多个任务子报表堆叠在一个联合视图中。' }, + showcase_task_overview: { + label: '任务概览(联合)', + description: '将多个任务子报表堆叠在一个联合视图中。', + // The joined report's block headings, keyed by block `name`. + blocks: { + open_block: { label: '未完成任务' }, + done_block: { label: '已完成任务' }, + }, + }, showcase_hours_by_status_chart: { label: '按状态统计工时(图表)', description: '以柱状图展示按状态统计的预计工时。' }, }, }, diff --git a/examples/app-showcase/test/zh-cn-report-blocks-inline-titles.test.ts b/examples/app-showcase/test/zh-cn-report-blocks-inline-titles.test.ts new file mode 100644 index 00000000000..bd887837061 --- /dev/null +++ b/examples/app-showcase/test/zh-cn-report-blocks-inline-titles.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Under zh-CN, the showcase's two authored headings that had no translation + * slot are served translated: + * + * - the joined report `showcase_task_overview` — its title was translated + * while its block headings ("Open Tasks", "Completed Tasks") stayed English; + * - the inline line-item grids on the invoice and expense-report create forms, + * headed by the child field's `inlineTitle` ("Line Items", "Expense Lines"). + * + * Both reach the console as the served document: the joined report renderer + * draws `block.label` off `GET /meta/report`, and the console's inline-subform + * pass copies the child field's `inlineTitle` off `GET /meta/object`. So the + * pin is the translator applied to the app's own metadata with the app's own + * bundle, exactly what the REST boundary does per request locale. + */ +import { describe, expect, it } from 'vitest'; +import { translateMetadataDocument, type TranslationBundle } from '@objectstack/spec/system'; +import { TaskOverviewReport } from '../src/ui/reports/index.js'; +import { InvoiceLine } from '../src/data/objects/invoice.object.js'; +import { ExpenseLine } from '../src/data/objects/expense-report.object.js'; +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; + +const bundle = ShowcaseTranslationBundle as unknown as TranslationBundle; +const zh = { locale: 'zh-CN' }; + +describe('showcase under zh-CN — authored headings with a slot of their own', () => { + it('serves the joined report\'s block headings translated, under its translated title', () => { + const out = translateMetadataDocument('report', TaskOverviewReport, bundle, zh); + expect(out.label).toBe('任务概览(联合)'); + expect(out.blocks.map((b: { name: string; label: unknown }) => [b.name, b.label])).toEqual([ + ['open_block', '未完成任务'], + ['done_block', '已完成任务'], + ]); + }); + + it('serves the invoice and expense-report inline grid headings translated', () => { + const invoiceLine = translateMetadataDocument('object', InvoiceLine, bundle, zh); + expect(invoiceLine.fields.invoice.inlineTitle).toBe('发票明细'); + const expenseLine = translateMetadataDocument('object', ExpenseLine, bundle, zh); + expect(expenseLine.fields.expense_report.inlineTitle).toBe('费用明细'); + }); + + it('leaves the authored English in place for a locale the bundle does not carry', () => { + const out = translateMetadataDocument('report', TaskOverviewReport, bundle, { locale: 'en' }); + expect(out.blocks.map((b: { label: unknown }) => b.label)).toEqual(['Open Tasks', 'Completed Tasks']); + expect(translateMetadataDocument('object', InvoiceLine, bundle, { locale: 'en' }).fields.invoice.inlineTitle) + .toBe('Line Items'); + }); +}); diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 321ccb3f9a1..5afbd13d342 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -353,7 +353,8 @@ const COVERAGE_SOURCE: Record // the `dashboard` bucket: the string is defined once, not once per // presentation. dataset: 'dataset', - // Report copy (`reports..label` / `.description`) — the author's own + // Report copy (`reports..label` / `.description`, and a joined report's + // block headings at `reports..blocks..*`) — the author's own // reports, listed by these two strings on the app's report index and headed // by the label on the report page, so it keeps its own bucket and reports as // `i18n/missing-report` rather than folding into `dashboard`. diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d17f41e4da5..ad6a953488e 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -1390,6 +1390,9 @@ export function collectExpectedEntries( { objectName }, ); pushOptional(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName }); + // The inline child grid's heading, drawn on the PARENT's form but + // addressed by the child field that declares it (`translateObject`). + pushOptional(out, ['objects', objectName, 'fields', fieldName, 'inlineTitle'], field.inlineTitle, 'field', { objectName }); // Options — accept either `{value, label}[]` arrays or a record map. // @@ -1871,8 +1874,9 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void { * Emit the report copy surface: * * reports..label / .description + * reports..blocks..label / .description * - * Read off the stack's `reports[]`. Served through `translateReport`, + * Read off the stack's `reports[]` and each joined report's `blocks[]`. Served through `translateReport`, * registered in `METADATA_DOCUMENT_TRANSLATORS`, so the REST boundary * localizes `GET /meta/report` (the list the console's report index and report * page read); this pass is the half that writes the skeleton and lets @@ -1881,8 +1885,9 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void { * `pushOptional` for both keys: a report's `description` is optional at the * authoring site, so a string nobody wrote is recorded without an `inline` and * never demanded as a translation. A report's `label` is required, so it is - * always authored text. A joined report's block labels and the embedded - * chart's copy are not on this face. + * always authored text. A block's `label` is optional (the renderer falls back + * to the block's machine `name`), so a block without one is recorded the same + * way and never demanded. The embedded chart's copy is not on this face. */ function walkReports(config: any, out: ExpectedEntry[]): void { const reports: any[] = Array.isArray(config?.reports) ? config.reports : []; @@ -1892,6 +1897,14 @@ function walkReports(config: any, out: ExpectedEntry[]): void { if (typeof name !== 'string' || name.length === 0) continue; pushOptional(out, ['reports', name, 'label'], report.label, 'report'); pushOptional(out, ['reports', name, 'description'], report.description, 'report'); + const blocks: any[] = Array.isArray(report.blocks) ? report.blocks : []; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const blockName = block.name; + if (typeof blockName !== 'string' || blockName.length === 0) continue; + pushOptional(out, ['reports', name, 'blocks', blockName, 'label'], block.label, 'report'); + pushOptional(out, ['reports', name, 'blocks', blockName, 'description'], block.description, 'report'); + } } } diff --git a/packages/cli/test/i18n-block-inline-title-walk.test.ts b/packages/cli/test/i18n-block-inline-title-walk.test.ts new file mode 100644 index 00000000000..9aa4812fe5b --- /dev/null +++ b/packages/cli/test/i18n-block-inline-title-walk.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Two authored, user-visible labels the extractor did not walk: +// +// - a joined report's block heading and caption +// (`reports..blocks..label` / `.description`); +// - a master-detail field's inline grid heading +// (`objects..fields..inlineTitle`). +// +// Both are served through their translators (`translateReport`, +// `translateObject`), so without this walk `os i18n extract` scaffolded no key +// for them and `os i18n check` measured a debt of zero while a zh-CN user read +// them in English. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +const config = () => ({ + objects: [ + { + name: 'invoice_line', + label: 'Invoice Line', + fields: { + invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', inlineEdit: 'grid', inlineTitle: 'Line Items' }, + // No inlineTitle: the grid is headed by the child's label, nothing to demand. + amount: { type: 'currency', label: 'Amount' }, + }, + }, + ], + reports: [ + { + name: 'task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [ + { name: 'open_block', label: 'Open Tasks', description: 'Still in flight', type: 'summary', dataset: 'tasks', values: ['n'] }, + // No label: the renderer falls back to the block's machine name. + { name: 'done_block', type: 'summary', dataset: 'tasks', values: ['n'] }, + ], + }, + ], +}); + +const byKey = () => new Map(collectExpectedEntries(config()).map((e) => [e.path.join('.'), e])); + +describe('i18n extract — joined report blocks', () => { + it('walks each block\'s label and description under the report', () => { + const keys = [...byKey().keys()].filter((k) => k.startsWith('reports.task_overview.blocks.')).sort(); + expect(keys).toEqual([ + 'reports.task_overview.blocks.done_block.description', + 'reports.task_overview.blocks.done_block.label', + 'reports.task_overview.blocks.open_block.description', + 'reports.task_overview.blocks.open_block.label', + ]); + }); + + it('seeds authored text and records an unwritten string without demanding it', () => { + const entries = byKey(); + expect(entries.get('reports.task_overview.blocks.open_block.label')) + .toMatchObject({ sourceValue: 'Open Tasks', inline: 'Open Tasks', source: 'report' }); + expect(entries.get('reports.task_overview.blocks.open_block.description')) + .toMatchObject({ sourceValue: 'Still in flight', source: 'report' }); + const unwritten = entries.get('reports.task_overview.blocks.done_block.label'); + expect(unwritten?.sourceValue).toBeUndefined(); + expect(unwritten?.inline).toBeUndefined(); + }); +}); + +describe('i18n extract — a field\'s inlineTitle', () => { + it('walks the heading on the child field that declares it, and only there', () => { + const entries = byKey(); + expect(entries.get('objects.invoice_line.fields.invoice.inlineTitle')) + .toMatchObject({ sourceValue: 'Line Items', inline: 'Line Items', source: 'field' }); + expect(entries.get('objects.invoice_line.fields.amount.inlineTitle')?.inline).toBeUndefined(); + }); +}); + +describe('i18n extract + check — both slots', () => { + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.reports?.task_overview?.blocks?.open_block?.label).toBeDefined(); + expect(zh?.objects?.invoice_line?.fields?.invoice?.inlineTitle).toBeDefined(); + expect(TranslationDataSchema.safeParse({ reports: zh.reports, objects: zh.objects }).success).toBe(true); + }); + + it('reports an untranslated block heading and inline heading, and clears them once translated', () => { + const coverage = (translations: unknown[]) => computeI18nCoverage({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations, + } as any).issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key); + + const before = coverage([{ 'zh-CN': { reports: { task_overview: { label: '任务概览' } } } }]); + expect(before).toContain('reports.task_overview.blocks.open_block.label'); + expect(before).toContain('reports.task_overview.blocks.open_block.description'); + expect(before).toContain('objects.invoice_line.fields.invoice.inlineTitle'); + // Nothing authored ⇒ nothing to translate. + expect(before).not.toContain('reports.task_overview.blocks.done_block.label'); + expect(before).not.toContain('objects.invoice_line.fields.amount.inlineTitle'); + + const after = coverage([{ + 'zh-CN': { + reports: { task_overview: { label: '任务概览', blocks: { open_block: { label: '未完成任务', description: '仍在进行中' } } } }, + objects: { invoice_line: { fields: { invoice: { inlineTitle: '发票明细' } } } }, + }, + }]); + expect(after).not.toContain('reports.task_overview.blocks.open_block.label'); + expect(after).not.toContain('reports.task_overview.blocks.open_block.description'); + expect(after).not.toContain('objects.invoice_line.fields.invoice.inlineTitle'); + }); +}); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9297c68fbf7..982d40c2cbe 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -559,6 +559,7 @@ "RenderOperationMessageOptions (interface)", "RenderValidationMessageInput (interface)", "RenderValidationMessageOptions (interface)", + "ReportBlockLike (interface)", "ReportLike (interface)", "ResolveOptions (interface)", "ResolvedBook (interface)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 5230dc0fbb4..e2dcb63e47c 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -441,6 +441,7 @@ "system/FieldEncryption:fieldName", "system/FieldEncryption:indexable", "system/FieldTranslation:help", + "system/FieldTranslation:inlineTitle", "system/FieldTranslation:label", "system/FieldTranslation:options", "system/FieldTranslation:placeholder", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 935a29155b9..d5a7c120bc8 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -535,6 +535,7 @@ "RenderOperationMessageOptions": "src/system/operation-message.ts#RenderOperationMessageOptions (interface)", "RenderValidationMessageInput": "src/system/validation-message.ts#RenderValidationMessageInput (interface)", "RenderValidationMessageOptions": "src/system/validation-message.ts#RenderValidationMessageOptions (interface)", + "ReportBlockLike": "src/system/i18n-resolver.ts#ReportBlockLike (interface)", "ReportLike": "src/system/i18n-resolver.ts#ReportLike (interface)", "ResolveOptions": "src/system/i18n-resolver.ts#ResolveOptions (interface)", "ResolvedBook": "src/system/book.zod.ts#ResolvedBook (interface)", diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index bba0cf8b57d..3979c0c9e77 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 36 | 0 | 0 | 0 | 0 | 36 | +| `translation` | 38 | 0 | 0 | 0 | 0 | 38 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 0c2ea7b8c3f..e8e745cbf50 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -123,6 +123,26 @@ "verifiedAt": "2026-10-25", "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupReportAttr (`reports..description`, read by translateReport)", "note": "Overlays the served report's own `description`." + }, + "blocks": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateReportBlocks (`reports..blocks.`, read by translateReport per joined block, keyed by the block's required `name`)", + "note": "A joined report's block heading and caption, drawn by the joined renderer off the served report. Seeded LIVE with its reader in the same change; the catalog yields per block to a string an org re-authored on the packaged report (ADR-0029 D9.2a).", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupReportBlockAttr (`reports..blocks..label`, read by translateReportBlocks)", + "note": "Overlays the served block's own `label`." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupReportBlockAttr (`reports..blocks..description`, read by translateReportBlocks)", + "note": "Overlays the served block's own `description`." + } + } } } }, diff --git a/packages/spec/src/system/i18n-field-inline-title.test.ts b/packages/spec/src/system/i18n-field-inline-title.test.ts new file mode 100644 index 00000000000..3e250509775 --- /dev/null +++ b/packages/spec/src/system/i18n-field-inline-title.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `objects..fields..inlineTitle` — the heading of the inline +// child grid a master-detail field draws on its PARENT's form, and the +// translator that overlays it on the served child object. +// +// Before this slot existed, every label of a zh-CN create form was Chinese +// except the inline child-grid headings, which printed the authored +// `inlineTitle` verbatim: the bundle had no key to put them under, so the +// coverage gate had nothing to miss. + +import { describe, it, expect } from 'vitest'; +import { + ObjectTranslationDataSchema, + TranslationDataSchema, + type TranslationBundle, +} from './translation.zod'; +import { translateMetadataDocument, translateObject } from './i18n-resolver'; +import { FieldSchema } from '../data/field.zod'; + +const PACKAGED = { + name: 'contract_version', + label: 'Contract Version', + pluralLabel: 'Versions', + fields: { + contract: { + name: 'contract', + type: 'master_detail', + label: 'Contract', + reference: 'contract', + inlineEdit: 'grid', + inlineTitle: 'Versions', + }, + amendment: { + name: 'amendment', + type: 'lookup', + label: 'Amendment', + reference: 'amendment', + inlineEdit: 'grid', + inlineTitle: 'Amended Versions', + }, + }, +}; + +const BUNDLE: TranslationBundle = { + 'zh-CN': { + objects: { + contract_version: { + fields: { + contract: { label: '合同', inlineTitle: '版本' }, + }, + }, + }, + }, +}; + +const clone = (value: V): V => JSON.parse(JSON.stringify(value)); +const fieldsOf = (out: unknown) => (out as { fields: Record> }).fields; + +describe('FieldTranslationSchema — inlineTitle', () => { + it('accepts the slot on a field translation, at the bundle and the per-object doors', () => { + expect(TranslationDataSchema.safeParse(BUNDLE['zh-CN']).success).toBe(true); + expect(ObjectTranslationDataSchema.safeParse({ fields: { contract: { inlineTitle: '版本' } } }).success).toBe(true); + }); + + it('refuses a non-string heading', () => { + const result = TranslationDataSchema.safeParse({ + objects: { contract_version: { fields: { contract: { inlineTitle: { 'zh-CN': '版本' } } } } }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]).toMatchObject({ + code: 'invalid_type', + path: ['objects', 'contract_version', 'fields', 'contract', 'inlineTitle'], + }); + }); +}); + +describe('translateObject — a field\'s inlineTitle', () => { + it('serves the translated heading on the child field, through the type dispatch, with no unrecognized key', () => { + const fields = fieldsOf(translateMetadataDocument('object', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' })); + expect(fields.contract.inlineTitle).toBe('版本'); + expect(fields.contract.label).toBe('合同'); + const parsed = FieldSchema.safeParse(fields.contract); + expect(parsed.success ? [] : parsed.error.issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + + it('keeps an untranslated heading as authored — never the child object\'s plural label', () => { + const fields = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN' })); + expect(fields.amendment.inlineTitle).toBe('Amended Versions'); + }); + + it('applies the BCP-47 ladder and the fallback chain', () => { + expect(fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh' })).contract.inlineTitle).toBe('版本'); + expect(fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'fr-FR', fallbackChain: ['zh-CN'] })).contract.inlineTitle) + .toBe('版本'); + }); + + describe('the catalog loses to an explicit override (ADR-0029 D9.2a)', () => { + it('applies while the served heading still equals the packaged field\'s', () => { + const fields = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })); + expect(fields.contract.inlineTitle).toBe('版本'); + }); + + it('yields to a heading the tenant re-authored, and still translates the field\'s label', () => { + const served = clone(PACKAGED); + served.fields.contract.inlineTitle = 'Revisions (ours)'; + const fields = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })); + expect(fields.contract.inlineTitle).toBe('Revisions (ours)'); + expect(fields.contract.label).toBe('合同'); + }); + }); +}); diff --git a/packages/spec/src/system/i18n-report-blocks.test.ts b/packages/spec/src/system/i18n-report-blocks.test.ts new file mode 100644 index 00000000000..b7680edf651 --- /dev/null +++ b/packages/spec/src/system/i18n-report-blocks.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `reports..blocks..…` — a joined report's block copy, and +// the translator that overlays it on the served `report` document. +// +// Before this slot existed, a localized tenant opening a joined report saw a +// translated report title over block headings left in the authoring language: +// the bundle had nowhere to put them, and `.strict()` refused a key a +// translator invented. + +import { describe, it, expect } from 'vitest'; +import { + TranslationDataSchema, + TranslationItemSchema, + PlatformTranslationDataSchema, + type TranslationBundle, +} from './translation.zod'; +import { translateMetadataDocument, translateReport } from './i18n-resolver'; +// Parsed by the authoring schema rather than hand-shaped, so a translator +// reading a member the schema does not carry cannot pass here. +import { ReportSchema } from '../ui/report.zod'; + +const joined = () => ReportSchema.parse({ + name: 'task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [ + { name: 'open_block', label: 'Open Tasks', description: 'Still in flight', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['n'] }, + { name: 'done_block', label: 'Completed Tasks', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['n'] }, + ], +}) as any; + +const bundle: TranslationBundle = { + 'zh-CN': { + reports: { + task_overview: { + label: '任务概览', + blocks: { + open_block: { label: '未完成任务', description: '仍在进行中' }, + done_block: { label: '已完成任务' }, + }, + }, + }, + }, +}; + +describe('TranslationDataSchema — reports..blocks', () => { + it('accepts the slot at all three doors — bundle entry, platform bundle and registered item', () => { + expect(TranslationDataSchema.safeParse(bundle['zh-CN']).success).toBe(true); + expect(PlatformTranslationDataSchema.safeParse(bundle['zh-CN']).success).toBe(true); + expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', ...bundle['zh-CN'] }).success).toBe(true); + }); + + it('closes a block entry at `label` and `description`', () => { + const result = TranslationDataSchema.safeParse({ + reports: { task_overview: { blocks: { open_block: { dataset: 'tasks' } } } }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('renames `title` onto `label` inside a block entry', () => { + const result = TranslationDataSchema.safeParse({ + reports: { task_overview: { blocks: { open_block: { title: '未完成任务' } } } }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]; + expect(issue).toMatchObject({ code: 'unrecognized_keys', keys: ['title'], path: ['reports', 'task_overview', 'blocks', 'open_block'] }); + expect(issue.message).toContain('`title` → `label`'); + }); +}); + +describe('translateReport — joined blocks', () => { + it('overlays each block heading and caption, keyed by block name', () => { + const out = translateMetadataDocument('report', joined(), bundle, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('任务概览'); + expect(out.blocks.map((b: any) => b.label)).toEqual(['未完成任务', '已完成任务']); + expect(out.blocks[0].description).toBe('仍在进行中'); + // Identity and the selection ride through untouched. + expect(out.blocks.map((b: any) => b.name)).toEqual(['open_block', 'done_block']); + expect(out.blocks[0].dataset).toBe('tasks'); + expect(out.blocks[1].description).toBeUndefined(); + }); + + it('translates the blocks when the bundle addresses only the blocks', () => { + const blocksOnly: TranslationBundle = { + 'zh-CN': { reports: { task_overview: { blocks: { done_block: { label: '已完成任务' } } } } }, + }; + const doc = joined(); + const out = translateReport(doc, blocksOnly, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('Task Overview'); + // An unaddressed block rides through as the same reference. + expect(out.blocks[0]).toBe(doc.blocks[0]); + expect(out.blocks[1].label).toBe('已完成任务'); + }); + + it('applies the BCP-47 ladder and the fallback chain to blocks', () => { + expect((translateReport(joined(), bundle, { locale: 'zh' }) as any).blocks[1].label).toBe('已完成任务'); + expect((translateReport(joined(), bundle, { locale: 'fr-FR', fallbackChain: ['zh-CN'] }) as any).blocks[1].label) + .toBe('已完成任务'); + }); + + it('does not mutate the input, and returns the same reference when no block matched', () => { + const doc = joined(); + const before = JSON.parse(JSON.stringify(doc)); + translateReport(doc, bundle, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + const other: TranslationBundle = { 'zh-CN': { reports: { task_overview: { blocks: { gone_block: { label: '无' } } } } } }; + expect(translateReport(doc, other, { locale: 'zh-CN' })).toBe(doc); + }); + + describe('the catalog loses to an explicit override (ADR-0029 D9.2a)', () => { + it('applies while a block still equals its packaged counterpart', () => { + const out = translateReport(joined(), bundle, { locale: 'zh-CN', packagedBase: joined() }) as any; + expect(out.blocks[0].label).toBe('未完成任务'); + }); + + it('yields to a block heading an org re-authored, and still translates the rest', () => { + const edited = joined(); + edited.blocks[0] = { ...edited.blocks[0], label: 'Open work (ours)' }; + const out = translateReport(edited, bundle, { locale: 'zh-CN', packagedBase: joined() }) as any; + expect(out.blocks[0].label).toBe('Open work (ours)'); + expect(out.blocks[0].description).toBe('仍在进行中'); + expect(out.blocks[1].label).toBe('已完成任务'); + }); + + it('treats a block the packaged report does not declare as authored after the fact', () => { + const added = joined(); + added.blocks.push({ name: 'late_block', label: 'Late Tasks', type: 'summary', dataset: 'tasks', values: ['n'] }); + const lateBundle: TranslationBundle = { + 'zh-CN': { reports: { task_overview: { blocks: { late_block: { label: '逾期任务' } } } } }, + }; + expect(translateReport(added, lateBundle, { locale: 'zh-CN', packagedBase: joined() })).toBe(added); + // No base supplied — nothing is inferred and the catalog applies. + expect((translateReport(added, lateBundle, { locale: 'zh-CN' }) as any).blocks[2].label).toBe('逾期任务'); + }); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 77823c09515..338707b36c2 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1769,7 +1769,7 @@ function isOptionValue(value: unknown): value is string | number | boolean { */ function packagedPart( parent: Record | undefined, - key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields', + key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields' | 'blocks', matches: (candidate: Record) => boolean, ): Record | undefined { if (parent === undefined) return undefined; @@ -2226,11 +2226,21 @@ export function translatePermissionSet( // Report metadata resolver (label + description) // ──────────────────────────────────────────────────────────────────────────── +/** Minimal shape of a joined report block consumed by {@link translateReport}. */ +export interface ReportBlockLike { + /** `JoinedReportBlockSchema.name` — the key `reports..blocks` addresses. */ + name?: string; + label?: unknown; + description?: unknown; + [key: string]: any; +} + /** Minimal shape of a report document consumed by {@link translateReport}. */ export interface ReportLike { name: string; label?: unknown; description?: unknown; + blocks?: ReportBlockLike[]; [key: string]: any; } @@ -2248,17 +2258,73 @@ function lookupReportAttr( return undefined; } +function lookupReportBlockAttr( + bundle: TranslationBundle | undefined, + name: string, + blockName: string, + attr: 'label' | 'description', + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.reports?.[name]?.blocks?.[blockName]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Overlay `reports..blocks..{label,description}` onto a + * joined report's `blocks[]`, judging each block against its packaged + * counterpart (matched by `name`). Returns `undefined` when no block changed, + * so {@link translateReport} can keep handing back the input reference. + */ +function translateReportBlocks( + blocks: unknown, + bundle: TranslationBundle, + name: string, + base: Record | undefined, + opts?: ResolveOptions, +): ReportBlockLike[] | undefined { + if (!Array.isArray(blocks)) return undefined; + let changed = false; + const rebuilt = blocks.map((block: ReportBlockLike) => { + if (!block || typeof block !== 'object') return block; + const blockName = block.name; + if (typeof blockName !== 'string' || blockName.length === 0) return block; + const packagedBlock = packagedPart(base, 'blocks', (candidate) => candidate.name === blockName); + const label = valueOverridesPackagedBase(packagedBlock, 'label', block.label) + ? undefined + : lookupReportBlockAttr(bundle, name, blockName, 'label', opts); + const description = valueOverridesPackagedBase(packagedBlock, 'description', block.description) + ? undefined + : lookupReportBlockAttr(bundle, name, blockName, 'description', opts); + if (label === undefined && description === undefined) return block; + changed = true; + return { + ...block, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + }; + }); + return changed ? rebuilt : undefined; +} + /** * Apply the active locale to a report metadata document — its `label` and - * `description` against `reports..label` / `.description`. The input - * document is not mutated, and it comes back as the same reference when the - * bundle carries neither key (or the served value overrides both). + * `description` against `reports..label` / `.description`, and each + * joined block's against `reports..blocks..label` / + * `.description`. The input document is not mutated, and it comes back as + * the same reference when the bundle carries none of those keys (or the + * served values override them). * * These are the two strings the console lists a report by on the app's report * index, and the report page's heading. Registered in * {@link METADATA_DOCUMENT_TRANSLATORS}, so the REST boundary localizes every * served report (`GET /meta/report`, list and item) with nothing else to - * remember. + * remember. A joined report's block `label` / `description` are the heading + * and caption its renderer draws above each block, read off the served + * document as is — so serving them translated here is the whole wiring. * * ## The catalog loses to an explicit override — ADR-0029 D9.2a * @@ -2292,11 +2358,13 @@ export function translateReport( const description = valueOverridesPackagedBase(base, 'description', doc.description) ? undefined : lookupReportAttr(bundle, name, 'description', opts); - if (label === undefined && description === undefined) return doc; + const blocks = translateReportBlocks(doc.blocks, bundle, name, base, opts); + if (label === undefined && description === undefined && blocks === undefined) return doc; return { ...doc, ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}), + ...(blocks !== undefined ? { blocks } : {}), }; } @@ -3233,6 +3301,8 @@ export interface ObjectFieldLike { name?: string; label?: string; description?: string; + /** `FieldSchema.inlineTitle` — the inline child grid's heading on the parent's form. */ + inlineTitle?: string; options?: Array<{ label?: string; value: string | number | boolean }>; [key: string]: any; } @@ -3256,7 +3326,7 @@ function lookupObjectFieldAttr( bundle: TranslationBundle | undefined, objectName: string, fieldName: string, - attr: 'label' | 'help' | 'description', + attr: 'label' | 'help' | 'description' | 'inlineTitle', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -3833,6 +3903,15 @@ function translateObjectListViews( * the base does not declare was authored after the fact, and its description * counts as diverged; no base supplied means nothing is inferred and the * catalog applies. + * + * ## A field's `inlineTitle` — the inline child grid's heading + * + * `objects..fields..inlineTitle` overlays the field's own + * `inlineTitle` under that same rule. The field sits on the CHILD object, but + * its heading is drawn on the PARENT's form: the console's inline-subform pass + * copies the served child field's `inlineTitle` as is, so serving it + * translated here is the whole wiring. An authored heading with no entry keeps + * its text; the child's plural label is never swapped in for it. */ export function translateObject( doc: T, @@ -3874,6 +3953,14 @@ export function translateObject( const translatedDescription = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); if (translatedDescription) next.description = translatedDescription; } + // The inline child grid's heading, under the same divergence rule. The + // console's inline-subform pass copies the served value onto the parent's + // form, so this is the one place it is localized. An untranslated heading + // keeps its authored text — never the child's plural label in its place. + if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'inlineTitle', def.inlineTitle)) { + const translatedInlineTitle = lookupObjectFieldAttr(bundle, objectName, name, 'inlineTitle', opts); + if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; + } if (Array.isArray(def.options)) { // A picklist-bound field is served with its list's options resolved // onto it (`PicklistServedFieldSchema`), and INHERITS the list's option diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 525953111fe..d8bd010ad95 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -125,6 +125,21 @@ export const FieldTranslationSchema = lazySchema(() => strictObject({ help: z.string().optional().describe('Translated help text'), placeholder: z.string().optional().describe('Translated placeholder text for form inputs'), options: z.record(z.string(), z.string()).optional().describe('Option value to translated label map'), + /** + * The field's own `inlineTitle` — the heading of the inline child grid a + * `master_detail` / `lookup` field with `inlineEdit` draws on its PARENT's + * form. The field lives on the child object, so the address is the child's: + * `objects..fields..inlineTitle`. Overlaid by + * `translateObject` on the served child object, which is what the console's + * inline-subform pass copies the heading from. + * + * No fallback to the child object's translated plural label: an authored + * `inlineTitle` with no entry here keeps its authored text, because a + * bundle translates an attribute and never swaps another attribute in over + * authored text. An absent `inlineTitle` already renders the child's + * (translated) label. + */ + inlineTitle: z.string().optional().describe("Translated inline master-detail grid title — overlays the field's own `inlineTitle`"), }).describe('Translation data for a single field')); export type FieldTranslation = z.input; @@ -1140,6 +1155,10 @@ const appTranslationDataShape = () => ({ * * reports..label → the report's own `label` * reports..description → the report's own `description` + * reports..blocks..label / .description + * → a joined report block's own `label` / + * `description`, keyed by the block's + * required `name` * * The two strings the console's report index lists each report by, and the * report page's heading. Overlaid by `translateReport` (`i18n-resolver.ts`) @@ -1148,9 +1167,9 @@ const appTranslationDataShape = () => ({ * O), so, as on a dashboard, the catalog yields to a label or description * an org has re-authored on top of the packaged report (ADR-0029 D9.2a). * - * The face stops at those two strings. A report's selection (`dataset`, - * `rows`, `columns`, `values`) is machine names whose display copy is the - * dataset's, translated at `datasets.`. + * The face stops at those strings. A report's selection (`dataset`, + * `rows`, `columns`, `values`), and a block's, is machine names whose + * display copy is the dataset's, translated at `datasets.`. * * ⚠️ `label` and `description` are `I18nLabelSchema` at the authoring site, * so a report's copy may already be an inline `{ en, 'zh-CN' }` map. @@ -1164,6 +1183,22 @@ const appTranslationDataShape = () => ({ }, { label: z.string().optional().describe("Translated report label — overlays the report's own `label`"), description: z.string().optional().describe("Translated report description — overlays the report's own `description`"), + /** + * A joined report's block copy, keyed by the block's `name` + * (`JoinedReportBlockSchema.name`, required). The heading and caption the + * joined renderer draws above each block; without this slot a localized + * report showed a translated title over block headings left in the + * authoring language. Overlaid by `translateReport`, block by block, under + * the same packaged-base rule as the report's own two strings. + */ + blocks: z.record(z.string(), strictObject({ + surface: 'this joined report block translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', heading: 'label' }, + }, { + label: z.string().optional().describe("Translated block label — overlays the joined report block's own `label`"), + description: z.string().optional().describe("Translated block description — overlays the joined report block's own `description`"), + })).optional().describe('Joined report block translations keyed by block name'), })).optional().describe('Report translations keyed by report name'), /**