diff --git a/.changeset/25788-translation-reports.md b/.changeset/25788-translation-reports.md new file mode 100644 index 00000000000..700f2bf3fc0 --- /dev/null +++ b/.changeset/25788-translation-reports.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/lint': minor +'@objectstack/metadata-protocol': minor +'@objectstack/rest': minor +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +feat(spec,cli): reports are translatable — `reports.` in the translation bundle + +Clause-②: yes +Metadata-protocol: added + +An app authored in one language listed its reports (`reports[].label`, `reports[].description`) in that language on the app's report index and the report page heading. Before this change the translation bundle had no key for them: `reports` was a retired object-first dialect key, so a bundle carrying it was refused by `os validate`, and `os i18n check` could not report the gap. + +- **`@objectstack/spec`:** `TranslationData` (and the registered `translation` item and the platform bundle, which share its groups) gains `reports`, keyed by the report's `name`, carrying `label` and `description` (strict: any other key is refused — `name` or `title` with a message pointing at `label`, and the singular spelling `report` with a message pointing at `reports`). `reports` leaves `LEGACY_OBJECT_FIRST_KEYS`, so a stored translation row carrying it now loads through the authored-row sync instead of being skipped as a retired dialect; the key's measured footprint in the legacy dialect was zero (#3778). A new translator, `translateReport`, overlays the two strings and is registered in the metadata-document dispatch, so `GET /meta/report` (list and item) is served in the request's locale. A key the bundle does not carry keeps the authored string. A report is overlay-editable (ADR-0126 Regime O), so the translator honours the packaged base: a label or description an org re-authored is served as written (ADR-0029 D9.2a), and the strings it did not touch are still translated. +- **`@objectstack/metadata-protocol`:** `getPackagedReportBase(name)` returns the shipped report document beneath any overlay, the base the REST read path hands the translator. +- **`@objectstack/rest`:** the meta read path supplies that base for `report`, as it does for `dashboard` and `view`. +- **`@objectstack/lint`:** `translation-target-unknown` reports a `reports.` key that names no report a package of the stack declares, as an error with the nearest declared name. +- **`@objectstack/cli`:** `os i18n extract` scaffolds `reports..label` / `.description` from the stack's `reports[]`, and `os i18n check` / `os lint` coverage report an untranslated one in its own bucket (`i18n/missing-report`). +- **`@object-ui/app-shell`, `@object-ui/i18n`:** the translation designer's preview draws the `reports` group (a "Reports" / 「报表」 category beside the others), and the console's translation loader recognises a payload that carries only `reports`. + +Example (`zh-CN` bundle of an English-authored app): + +```ts +reports: { opps_by_stage: { label: '各阶段商机', description: '按阶段汇总的商机' } }, +``` diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 1fa0f5e09bd..d7d17e35f80 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -399,7 +399,7 @@ Three rules the shape enforces, all of them closed since #4001: - **The top level is the declared group set and nothing else** — `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, - `pages`, `flows`, `positions`, `permissions`, `metadataForms`, + `reports`, `pages`, `flows`, `positions`, `permissions`, `metadataForms`, `settingsCommon`. A group invented beside them (`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name** at both authoring doors, with the group to use instead named in the @@ -483,7 +483,7 @@ what the overlay writes back as the node's `title`. The contract is `t(key, locale, params?)` — the target locale is always passed explicitly, and the key itself is returned when no translation is found (there is no `defaultValue` option). The key is a **dot path into `TranslationData`**, -so it starts at one of the thirteen declared groups. +so it starts at one of the fourteen declared groups. ```typescript const i18n = kernel.getService('i18n'); @@ -1063,6 +1063,7 @@ beneath an action entry are judged the same way under either. | `flows.{flow}` | a flow's machine `name`, declared | | `positions.{name}` | a position's `name`, declared, or an audience anchor (`everyone`, `guest`) | | `permissions.{name}` | a permission set's `name`, declared | +| `reports.{name}` | a report's `name`, declared | | `flows.{flow}.screens.{node}` | the `id` of a `type: 'screen'` node in that flow, nodes nested in loop, parallel and try/catch regions included | | `flows.{flow}.screens.{node}.fields.{field}` | a `config.fields[].name` on that screen — an object-form screen (`config.objectName`) declares none, and its input labels live under `objects.{objectName}.fields` | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9d2f17507ec..d250cff47d0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1776,6 +1776,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 | | **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 a7ec6abeaf2..644d112e4c7 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -177,6 +177,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 | | **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 | @@ -247,6 +248,13 @@ Translation data for a single object | **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | | **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | +### Nested Shape: `PlatformTranslationData.reports[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated report label — overlays the report's own `label` | +| **description** | `string` | optional | Translated report description — overlays the report's own `description` | + ### Nested Shape: `PlatformTranslationData.pages[string]` | Property | Type | Required | Description | @@ -397,6 +405,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 | | **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 | @@ -466,6 +475,13 @@ Translation data for a single object | **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | | **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | +### Nested Shape: `TranslationData.reports[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated report label — overlays the report's own `label` | +| **description** | `string` | optional | Translated report description — overlays the report's own `description` | + ### Nested Shape: `TranslationData.pages[string]` | Property | Type | Required | Description | @@ -566,6 +582,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 | | **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 | @@ -645,6 +662,13 @@ Translation data for a single object | **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | | **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | +### Nested Shape: `TranslationItem.reports[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated report label — overlays the report's own `label` | +| **description** | `string` | optional | Translated report description — overlays the report's own `description` | + ### Nested Shape: `TranslationItem.pages[string]` | Property | Type | Required | Description | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 521aa97e574..370a57bc191 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -78,6 +78,7 @@ export default defineStack({ | Dashboard global-filter label and static option labels | `dashboards..globalFilters..label` / `.options.` — the key is the filter's `name`, or its `field` when it authors no `name`; an option is keyed by its `value` spelled as a string | | Analytics dataset label / description | `datasets..label` / `description` | | Dataset dimension and measure labels | `datasets..dimensions..label` / `datasets..measures..label` | +| Report label / description | `reports..label` / `description` — keyed by the report's `name`; overlaid on every served report, which is what the console's report index lists and the report page is headed by. A label or description an org re-authored on a packaged report is served as edited | | Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header | | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | | Page tab labels (`page:tabs` items) | `pages..components..items..label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none | @@ -89,7 +90,7 @@ export default defineStack({ | A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below | The metadata types resolved per request are **object, view, action, app, -dashboard, dataset, page, picklist, position and permission** — a field's labels are translated as part of +dashboard, dataset, report, page, picklist, position and permission** — a field's labels are translated as part of its object document, and so are the labels of any actions the object declares inline (#3370). Before that, an object document went out with its authored action 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 8c8796fa8da..eeed4d62d38 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/` | 381 | +| `system/` | 382 | diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 3b152cb4909..413d60c5d65 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1337,5 +1337,13 @@ export const ShowcaseTranslationBundle = { showcase_field_ops_delegate: { label: '现场运营委派管理员' }, showcase_client_liaison: { label: '示例客户联络人' }, }, + // The reports, as the report index lists them and each report page is + // headed. Counted by `check-i18n-coverage` like the groups above. + reports: { + showcase_hours_by_status: { label: '按状态统计工时(汇总)', description: '按任务状态分组的预计工时。' }, + showcase_status_priority_matrix: { label: '状态 × 优先级(矩阵)', description: '按状态和优先级交叉统计的预计工时。' }, + showcase_task_overview: { label: '任务概览(联合)', description: '将多个任务子报表堆叠在一个联合视图中。' }, + showcase_hours_by_status_chart: { label: '按状态统计工时(图表)', description: '以柱状图展示按状态统计的预计工时。' }, + }, }, }; diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 77b311d56be..d9d2c7b4d60 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -91,6 +91,15 @@ export const jaJP: TranslationData = { description: '個人タスク管理アプリケーション', }, }, + // The app's reports, as its report index lists them and each report page is + // headed (`reports..label` / `.description`). + reports: { + tasks_by_status: { label: 'ステータス別タスク', description: 'ステータス別に集計したタスク' }, + tasks_by_priority: { label: '優先度別タスク', description: '優先度レベル別に集計したタスク' }, + tasks_by_owner: { label: '担当者別タスク', description: '担当者別のタスク集計' }, + completed_tasks: { label: '完了済みタスク', description: '作業時間の記録付きの完了済みタスク' }, + time_tracking: { label: '作業時間レポート', description: '見積時間と実績時間の分析' }, + }, // The quick-add wizard: the flow's name in the runner header and completion // toast, each screen's heading and each field label. flows: { diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 2978abb163d..4653c7d8d8b 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -99,6 +99,15 @@ export const zhCN: TranslationData = { description: '个人任务管理应用', }, }, + // The app's reports, as its report index lists them and each report page is + // headed (`reports..label` / `.description`). + reports: { + tasks_by_status: { label: '按状态统计任务', description: '按状态分组的任务汇总' }, + tasks_by_priority: { label: '按优先级统计任务', description: '按优先级分组的任务汇总' }, + tasks_by_owner: { label: '按负责人统计任务', description: '按负责人汇总的任务' }, + completed_tasks: { label: '已完成任务', description: '所有已完成任务及其工时记录' }, + time_tracking: { label: '工时跟踪报表', description: '预计工时与实际工时分析' }, + }, // The quick-add wizard: the flow's name in the runner header and completion // toast, each screen's heading and each field label. flows: { diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 66fea336b68..321ccb3f9a1 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -67,6 +67,7 @@ export interface CoverageIssue { | 'dashboard' | 'widget' | 'dataset' + | 'report' | 'picklist' | 'page' | 'flow' @@ -352,6 +353,11 @@ 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 + // 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`. + report: 'report', // Shared option-list copy (`picklists.

.label`, `.options.`) — the // author's own vocabulary, translated once for every field that references // it, so it keeps its own bucket and reports as `i18n/missing-picklist`. @@ -396,6 +402,7 @@ const SOURCE_NOUN: Record = { dashboard: 'Dashboard', widget: 'Widget', dataset: 'Dataset', + report: 'Report', picklist: 'Picklist', page: 'Page', flow: 'Flow', @@ -440,6 +447,7 @@ const SOURCE_SURFACE: Record = { dashboard: 'dashboards', widget: 'widgets', dataset: 'datasets', + report: 'reports', picklist: 'picklists', page: 'pages', flow: 'flow screens, approval steps, toasts and refusals', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 73ad3d73579..d17f41e4da5 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -102,6 +102,7 @@ * datasets..label / .description * datasets..dimensions..label * datasets..measures..label + * reports..label / .description * picklists..label * picklists..options. (a `picklistExtensions` entry's options too) * pages..label / .description @@ -223,6 +224,7 @@ export interface ExpectedEntry { | 'dashboard' | 'widget' | 'dataset' + | 'report' | 'picklist' | 'page' | 'flow' @@ -1617,6 +1619,9 @@ export function collectExpectedEntries( // ── Analytics datasets (`datasets..…`) ───────────────────── walkDatasets(config, out); + // ── Reports (`reports..…`) ───────────────────────────────── + walkReports(config, out); + // ── Shared option lists (`picklists..…`) ──────────────────── walkPicklists(config, out); @@ -1860,6 +1865,36 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void { } } +// ─── Reports (`reports..…`) ────────────────────────────────────── + +/** + * Emit the report copy surface: + * + * reports..label / .description + * + * Read off the stack's `reports[]`. 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 + * `os i18n check` see the gap. + * + * `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. + */ +function walkReports(config: any, out: ExpectedEntry[]): void { + const reports: any[] = Array.isArray(config?.reports) ? config.reports : []; + for (const report of reports) { + if (!report || typeof report !== 'object') continue; + const name = report.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushOptional(out, ['reports', name, 'label'], report.label, 'report'); + pushOptional(out, ['reports', name, 'description'], report.description, 'report'); + } +} + // ─── Security catalog (`positions..…`, `permissions..…`) ─── /** diff --git a/packages/cli/test/i18n-report-walk.test.ts b/packages/cli/test/i18n-report-walk.test.ts new file mode 100644 index 00000000000..c4cf5719e8f --- /dev/null +++ b/packages/cli/test/i18n-report-walk.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `reports..…` — the extractor walks the report translation group. +// +// A report is listed by its label and description on the app's report index, +// and headed by its label on the report page. Both are served through +// `translateReport`, so without this walk `os i18n extract` scaffolded no key +// for them and `os i18n check` measured a debt of zero while every report +// listed in the authoring language. + +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 = () => ({ + reports: [ + { + name: 'opps_by_stage', + label: 'Opportunities by Stage', + description: 'Summary of opportunities grouped by stage', + type: 'summary', + dataset: 'pipeline', + rows: ['stage'], + values: ['amount'], + }, + // No description: nothing an author wrote, so nothing is demanded. + { name: 'leads_by_source', label: 'Leads by Source', type: 'summary', dataset: 'leads', values: ['n'] }, + ], +}); + +const keysOf = (group: string) => + collectExpectedEntries(config()) + .filter((e) => e.path[0] === group) + .map((e) => e.path.join('.')) + .sort(); + +describe('i18n extract — the `reports` group', () => { + it('walks each report\'s label and description', () => { + expect(keysOf('reports')).toEqual([ + 'reports.leads_by_source.description', + 'reports.leads_by_source.label', + 'reports.opps_by_stage.description', + 'reports.opps_by_stage.label', + ]); + }); + + it('seeds authored text and records an unwritten string without demanding it', () => { + const byKey = new Map(collectExpectedEntries(config()).map((e) => [e.path.join('.'), e])); + expect(byKey.get('reports.opps_by_stage.label')) + .toMatchObject({ sourceValue: 'Opportunities by Stage', inline: 'Opportunities by Stage', source: 'report' }); + expect(byKey.get('reports.opps_by_stage.description')) + .toMatchObject({ sourceValue: 'Summary of opportunities grouped by stage', source: 'report' }); + const unwritten = byKey.get('reports.leads_by_source.description'); + expect(unwritten?.sourceValue).toBeUndefined(); + expect(unwritten?.inline).toBeUndefined(); + }); + + 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(Object.keys(zh?.reports ?? {}).sort()).toEqual(['leads_by_source', 'opps_by_stage']); + expect(zh?.reports?.opps_by_stage?.label).toBeDefined(); + expect(TranslationDataSchema.safeParse({ reports: zh.reports }).success).toBe(true); + }); + + it('reports an untranslated label under its own bucket', () => { + const report = computeI18nCoverage({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { reports: { opps_by_stage: { label: '各阶段商机', description: '按阶段汇总的商机' } } } }, + ], + }); + const zhIssues = report.issues.filter((i) => i.locale === 'zh-CN'); + const keys = zhIssues.map((i) => i.key); + expect(keys).toContain('reports.leads_by_source.label'); + expect(keys).not.toContain('reports.opps_by_stage.label'); + expect(keys).not.toContain('reports.opps_by_stage.description'); + // Nothing authored ⇒ nothing to translate. + expect(keys).not.toContain('reports.leads_by_source.description'); + expect(zhIssues.find((i) => i.key === 'reports.leads_by_source.label')?.source).toBe('report'); + }); +}); diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 62ede30470a..971a4af8a60 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3486,7 +3486,8 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = { '`actionUrl`, not its label. `picklists.LIST` a picklist\'s `name` that a package of this ' + 'stack declares. `positions.NAME` a position\'s `name` that a package of this stack ' + 'declares, or an audience anchor (`everyone`, `guest`); `permissions.NAME` a permission ' + - 'set\'s `name` that a package of this stack declares.', + 'set\'s `name` that a package of this stack declares. `reports.NAME` a report\'s `name` that ' + + 'a package of this stack declares.', // The terminal toast keys are held to `FLOW_TERMINAL_MESSAGE_KEYS` by the rule's test. '`flows.FLOW` a flow\'s machine `name`; `.successMessage` / `.errorMessage` a toast the flow ' + 'itself authors (a bundle cannot add one); `.screens.NODE` the `id` of a `type: \'screen\'` ' + diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 4e920e428c4..686ce4300f2 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -4474,3 +4474,41 @@ describe('validateTranslationReferences — the security catalog (positions, per .toEqual([]); }); }); + +describe('validateTranslationReferences — reports', () => { + const stack = (data: Record, extra: Record = {}) => ({ + reports: [{ name: 'opps_by_stage', label: 'Opportunities by Stage', type: 'summary', dataset: 'pipeline', values: ['amount'] }], + ...extra, + translations: [{ en: data }], + }); + + it('a declared name is clean', () => { + expect(validateTranslationReferences(stack({ + reports: { opps_by_stage: { label: 'Opportunities by Stage', description: 'Pipeline grouped by stage' } }, + }))).toEqual([]); + }); + + it('a name no package declares is translation-target-unknown, at the named-item groups\' error', () => { + const findings = validateTranslationReferences(stack({ reports: { opps_by_stgae: { label: 'x' } } })); + expect(findings.map((f) => [f.rule, f.severity, f.path, f.where])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0].en.reports.opps_by_stgae', 'locale "en" · report "opps_by_stgae"'], + ]); + expect(findings[0].message).toContain('Did you mean "opps_by_stage"?'); + }); + + it('judges the group against its own collection — a dashboard name under `reports` is an orphan', () => { + const findings = validateTranslationReferences(stack( + { reports: { ops: { label: 'x' } } }, + { dashboards: [{ name: 'ops', label: 'Operations', widgets: [] }] }, + )); + expect(findings.map((f) => f.path)).toEqual(['translations[0].en.reports.ops']); + }); + + it('resolves a name a sibling package of the artifact declares', () => { + const findings = validateTranslationReferences(stack( + { reports: { leads_by_source: { label: 'Leads by Source' } } }, + { packages: [{ manifest: { reports: [{ name: 'leads_by_source', label: '线索来源', type: 'summary', dataset: 'leads', values: ['n'] }] } }] }, + )); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 6dee282662a..7d96c4db3e8 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -155,6 +155,13 @@ * environment, so a key naming one resolves and is never an orphan. Leaf keys * below the name are the schema's (`label` / `description`, strict). * + * ── Reports ────────────────────────────────────────────────────────────── + * + * `reports.` resolves against `Report.name`, the name `translateReport` + * looks a served report up by, with the same verdict: a name no package of + * this stack declares is an orphan, `translation-target-unknown` (`error`). + * Leaf keys below the name are the schema's (`label` / `description`, strict). + * * ── Cross-package objects ──────────────────────────────────────────────── * * A stack legitimately translates objects it does not define — `sys_user`'s @@ -199,8 +206,8 @@ * is the one reader of that carrier, and every collection it may be asked for * is a key whose `COMPOSE_KEY_DISPOSITIONS` disposition puts it inside an * ADR-0130 D4 entry's assembled body: `objects` (#19064) plus `views`, - * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349), and - * `positions` and `permissions`. + * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349), + * `positions` and `permissions`, and `reports`. * * ⚠️ `apps` reads BOTH that carrier and `navigationContributions`, and they * answer different questions — what a sibling DECLARES, and what this package @@ -507,6 +514,8 @@ interface Universe { positions: Set; /** Permission-set names (`PermissionSet.name`) a `permissions.` key may name. */ permissionSets: Set; + /** Report names (`Report.name`) a `reports.` key may name. */ + reports: Set; /** * Page name (`Page.name`) → every component id the page ADDRESSES * (`pages..components.`), with that component's type and, for a @@ -1680,6 +1689,8 @@ function buildUniverse(stack: AnyRec): Universe { const positions = namesOf('positions'); for (const anchor of AUDIENCE_ANCHOR_POSITIONS) positions.add(anchor); const permissionSets = namesOf('permissions'); + // `reports` carries disposition `concat` too, so a sibling's reports resolve. + const reports = namesOf('reports'); return { objects, @@ -1694,6 +1705,7 @@ function buildUniverse(stack: AnyRec): Universe { extendedPicklists, positions, permissionSets, + reports, pages, }; } @@ -2146,12 +2158,13 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind }); } - // ── positions. / permissions. ───────────────────────── - const securityCatalog = [ + // ── positions. / permissions. / reports. ──────── + const namedItemGroups = [ { group: 'positions', noun: 'position', known: universe.positions, hint: "Match the key to a position's `name`, or drop it." }, { group: 'permissions', noun: 'permission set', known: universe.permissionSets, hint: "Match the key to a permission set's `name`, or drop it." }, + { group: 'reports', noun: 'report', known: universe.reports, hint: "Match the key to a report's `name`, or drop it." }, ] as const; - for (const { group, noun, known, hint } of securityCatalog) { + for (const { group, noun, known, hint } of namedItemGroups) { for (const name of Object.keys(asRecord(rawData[group]))) { if (known.has(name)) continue; orphan( diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 684c73ccc80..740bb82804e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9021,12 +9021,39 @@ export class ObjectStackProtocolImplementation implements } /** - * The one body behind {@link getPackagedDashboardBase} and - * {@link getPackagedViewBase}: {@link lookupArtifactItem}, and nothing - * wider, so only an item whose `_packageId` marks a genuine code package - * answers, and an overlay hydrated under the plain registry key never does. + * [#25788] The PACKAGED (code-layer) declaration of a REPORT — the report + * item a code package ships, before any tenant `sys_metadata` overlay. + * The report twin of {@link getPackagedDashboardBase}, answered the same + * way, and the fact `translateReport` (`@objectstack/spec/system`) + * compares against as its `packagedBase`: the i18n catalog + * (`reports..label` / `.description`) is the packaged translation of + * exactly this body, so it may only replace a string the served report + * still carries unchanged from it (ADR-0029 D9.2a — an explicit override + * beats a packaged default; ADR-0126 Regime O — a packaged report is + * overlay-editable, and a published overlay is what every read serves). + * + * Landed with the report translator itself, so a report was never served + * translated without its base: an org's re-authored report label is + * never replaced by the catalog's translation of the shipped one. + * + * A report has no extension fold, so the artifact item IS the packaged + * declaration. Returns `undefined` for a runtime/tenant-authored report + * (no code package ships it), an unknown or empty name, and a registry + * double without the lookup — "no packaged baseline", read by the + * translator as the catalog applying unchanged. + */ + getPackagedReportBase(name: string): unknown { + return this.packagedArtifactBase('report', name); + } + + /** + * The one body behind {@link getPackagedDashboardBase}, + * {@link getPackagedViewBase} and {@link getPackagedReportBase}: + * {@link lookupArtifactItem}, and nothing wider, so only an item whose + * `_packageId` marks a genuine code package answers, and an overlay + * hydrated under the plain registry key never does. */ - private packagedArtifactBase(type: 'dashboard' | 'view', name: string): unknown { + private packagedArtifactBase(type: 'dashboard' | 'view' | 'report', name: string): unknown { if (typeof name !== 'string' || name === '') return undefined; try { return this.lookupArtifactItem(type, name); diff --git a/packages/objectql/src/protocol-packaged-report-base.test.ts b/packages/objectql/src/protocol-packaged-report-base.test.ts new file mode 100644 index 00000000000..ee69f37bac2 --- /dev/null +++ b/packages/objectql/src/protocol-packaged-report-base.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25788 — `ObjectStackProtocolImplementation.getPackagedReportBase`, against a + * REAL `SchemaRegistry`: the twin of `protocol-packaged-dashboard-base.test.ts`. + * + * A report is overlay-editable (ADR-0126 Regime O), and the translation bundle + * now carries `reports..label` / `.description`, so `translateReport` + * must lose to a string an org re-authored (ADR-0029 D9.2a). It decides that by + * comparing against the PACKAGED declaration, and that comparison is only as + * good as the body this accessor returns: the one the code package shipped, + * never an overlay hydrated under the plain registry key. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { translateReport, type TranslationBundle } from '@objectstack/spec/system'; +import { SchemaRegistry } from './registry.js'; + +const PKG = 'com.example.showcase'; +const REPORT = 'opps_by_stage'; + +const SHIPPED_LABEL = 'Opportunities by Stage'; +const EDITED_LABEL = 'Pipeline by stage (ours)'; + +/** The report as the code package ships it. */ +const packagedBody = () => ({ + name: REPORT, + label: SHIPPED_LABEL, + description: 'Summary of opportunities grouped by stage', + type: 'summary', + dataset: 'pipeline', + rows: ['stage'], + values: ['amount'], +}); + +/** The same report as an org's published overlay stores it — the label edited. */ +const overlayBody = () => ({ ...packagedBody(), label: EDITED_LABEL }); + +const BUNDLE: TranslationBundle = { + 'zh-CN': { reports: { [REPORT]: { label: '各阶段商机', description: '按阶段汇总的商机' } } }, +}; + +function makeSession() { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const protocol: any = new ObjectStackProtocolImplementation({ registry } as any, undefined, 'env_test'); + return { protocol, registry }; +} + +describe('#25788 getPackagedReportBase — the packaged declaration, never an overlay', () => { + it('answers the body the code package shipped', () => { + const s = makeSession(); + s.registry.registerItem('report', packagedBody(), 'name', PKG); + const base = s.protocol.getPackagedReportBase(REPORT); + expect(base?.label).toBe(SHIPPED_LABEL); + expect(base?._packageId).toBe(PKG); + }); + + it('is immune to an overlay hydrated under the plain registry key', () => { + const s = makeSession(); + s.registry.registerItem('report', packagedBody(), 'name', PKG); + s.registry.registerItem('report', overlayBody(), 'name'); + + // The trap is live: the plain registry read answers the overlay… + expect((s.registry.getItem('report', REPORT) as any)?.label).toBe(EDITED_LABEL); + // …and the packaged base does not move. + expect(s.protocol.getPackagedReportBase(REPORT)?.label).toBe(SHIPPED_LABEL); + }); + + it('is undefined for a report no code package ships, an unknown name, and an empty name', () => { + const s = makeSession(); + s.registry.registerItem('report', { ...packagedBody(), name: 'tenant_report' }, 'name'); + expect(s.protocol.getPackagedReportBase('tenant_report')).toBeUndefined(); + expect(s.protocol.getPackagedReportBase('no_such_report')).toBeUndefined(); + expect(s.protocol.getPackagedReportBase('')).toBeUndefined(); + }); + + it('is undefined when the host registry cannot answer', () => { + const partial: any = new ObjectStackProtocolImplementation({ registry: {} } as any, undefined, 'env_test'); + expect(partial.getPackagedReportBase(REPORT)).toBeUndefined(); + const none: any = new ObjectStackProtocolImplementation({} as any, undefined, 'env_test'); + expect(none.getPackagedReportBase(REPORT)).toBeUndefined(); + }); + + it('hands `translateReport` the base it needs: the edit is kept, the untouched description translated', () => { + const s = makeSession(); + s.registry.registerItem('report', packagedBody(), 'name', PKG); + s.registry.registerItem('report', overlayBody(), 'name'); + const packagedBase = s.protocol.getPackagedReportBase(REPORT); + + const served = translateReport(overlayBody(), BUNDLE, { locale: 'zh-CN', packagedBase }) as any; + expect(served.label).toBe(EDITED_LABEL); + expect(served.description).toBe('按阶段汇总的商机'); + + const shipped = translateReport(packagedBody(), BUNDLE, { locale: 'zh-CN', packagedBase }) as any; + expect(shipped.label).toBe('各阶段商机'); + }); +}); diff --git a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts index aea6edf7abc..9670f6addf6 100644 --- a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts +++ b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts @@ -28,6 +28,10 @@ * `zh-CN`; the unedited widget / view stays translated; a reset (no overlay * row) restores the shipped body, translated; a dashboard whose catalog * carries no widget titles (the showcase control) serves the edit unchanged. + * + * #25788 — a REPORT joined the table when it became translatable: it is + * overlay-editable too (ADR-0126 Regime O), so `translateReport` landed with + * its base (`getPackagedReportBase`) and §4 pins the same three facts for it. */ import { describe, it, expect, vi } from 'vitest'; @@ -56,6 +60,21 @@ const EDITED_TITLE = 'Total Users (edited-20730)'; const SHIPPED_LABEL = 'In Progress'; const EDITED_LABEL = 'In Progress (edited-20730)'; +const REPORT = 'opps_by_stage'; +const SHIPPED_REPORT_LABEL = 'Opportunities by Stage'; +const EDITED_REPORT_LABEL = 'Opportunities by Stage (edited-25788)'; + +/** The report as the code package ships it. */ +const packagedReport = () => ({ + name: REPORT, + label: SHIPPED_REPORT_LABEL, + description: 'Summary of opportunities grouped by stage', + type: 'summary', + dataset: 'pipeline', + rows: ['stage'], + values: ['amount'], +}); + /** The dashboard as the code package ships it. */ const packagedDashboard = () => ({ name: DASH, @@ -117,6 +136,7 @@ const BUNDLE: Record = { [CONTROL_DASH]: { label: 'Showcase Overview' }, }, objects: { [OBJ]: { _views: { in_progress: { label: SHIPPED_LABEL }, urgent: { label: 'Urgent' } } } }, + reports: { [REPORT]: { label: SHIPPED_REPORT_LABEL, description: 'Summary of opportunities grouped by stage' } }, }, 'zh-CN': { dashboards: { @@ -129,6 +149,7 @@ const BUNDLE: Record = { [CONTROL_DASH]: { label: '展示概览' }, }, objects: { [OBJ]: { _views: { in_progress: { label: '进行中' }, urgent: { label: '紧急' } } } }, + reports: { [REPORT]: { label: '各阶段商机', description: '按阶段汇总的商机' } }, }, }; @@ -151,7 +172,7 @@ const LOCALES = ['en', 'zh-CN'] as const; // The host: REAL registry, REAL protocol, the REST routes // --------------------------------------------------------------------------- -type OverlayRow = { type: 'dashboard' | 'view'; name: string; body: Record }; +type OverlayRow = { type: 'dashboard' | 'view' | 'report'; name: string; body: Record }; /** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ const whereValueMatches = (actual: unknown, want: unknown): boolean => @@ -181,6 +202,7 @@ function makeHost(overlays: OverlayRow[], who: Who) { registry.registerItem('dashboard', packagedControlDashboard(), 'name', SHOWCASE_PKG); registry.registerItem('view', { name: OBJ, ...taskContainer() }, 'name', SHOWCASE_PKG); for (const item of expandViewContainer(OBJ, taskContainer())) registry.registerItem('view', item, 'name', SHOWCASE_PKG); + registry.registerItem('report', packagedReport(), 'name', SHOWCASE_PKG); const rows = overlays.map((o, i) => ({ id: `r_${i}`, @@ -257,6 +279,7 @@ const titleOf = (doc: any, id: string) => (doc?.widgets as any[] | undefined)?.f const EDITED_DASHBOARD: OverlayRow = { type: 'dashboard', name: DASH, body: withWidgetTitle(packagedDashboard(), 'widget_total_users', EDITED_TITLE) }; const EDITED_VIEW: OverlayRow = { type: 'view', name: VIEW, body: { ...packagedViewItem(VIEW), label: EDITED_LABEL } }; +const EDITED_REPORT: OverlayRow = { type: 'report', name: REPORT, body: { ...packagedReport(), label: EDITED_REPORT_LABEL } }; const READS = ['item', 'listed'] as const; const cells = () => (Object.keys(CALLERS) as Who[]).flatMap((who) => @@ -271,6 +294,7 @@ describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolve getPackagedObjectBase: vi.fn((name: string) => ({ kind: 'object', name })), getPackagedDashboardBase: vi.fn((name: string) => ({ kind: 'dashboard', name })), getPackagedViewBase: vi.fn((name: string) => ({ kind: 'view', name })), + getPackagedReportBase: vi.fn((name: string) => ({ kind: 'report', name })), }); it('asks each type its own accessor, handing the served name through unchanged', () => { @@ -279,19 +303,23 @@ describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolve expect(packagedObjectBaseOf(p, 'dashboard', DASH)).toEqual({ kind: 'dashboard', name: DASH }); // The qualified registry name — never the bare `in_progress` the catalog uses. expect(packagedObjectBaseOf(p, 'view', VIEW)).toEqual({ kind: 'view', name: VIEW }); + // [#25788] A report is overlay-editable too (ADR-0126 Regime O). + expect(packagedObjectBaseOf(p, 'report', REPORT)).toEqual({ kind: 'report', name: REPORT }); expect(p.getPackagedObjectBase).toHaveBeenCalledTimes(1); expect(p.getPackagedDashboardBase).toHaveBeenCalledTimes(1); expect(p.getPackagedViewBase).toHaveBeenCalledTimes(1); + expect(p.getPackagedReportBase).toHaveBeenCalledTimes(1); }); it('asks nothing for a type with no packaged base', () => { const p = protocol(); - for (const type of ['action', 'app', 'dataset', 'page', 'report', 'toString', 'constructor']) { + for (const type of ['action', 'app', 'dataset', 'page', 'toString', 'constructor']) { expect(packagedObjectBaseOf(p, type, 'x'), type).toBeUndefined(); } expect(p.getPackagedObjectBase).not.toHaveBeenCalled(); expect(p.getPackagedDashboardBase).not.toHaveBeenCalled(); expect(p.getPackagedViewBase).not.toHaveBeenCalled(); + expect(p.getPackagedReportBase).not.toHaveBeenCalled(); }); it('answers undefined for an empty name, a protocol without the accessor, and an accessor that throws', () => { @@ -366,3 +394,25 @@ describe('#20730 §3 view — a published overlay on a packaged view is what bot expect((await host[read]('view', VIEW, locale))?.label).toBe(locale === 'zh-CN' ? '进行中' : SHIPPED_LABEL); }); }); + +// --------------------------------------------------------------------------- +// §4 — a published report overlay beats the packaged catalog (#25788) +// --------------------------------------------------------------------------- + +describe('#25788 §4 report — a published overlay on a packaged report is what both /meta reads serve', () => { + it.each(cells())('$read read, $who, $locale: the edited label serves the edit', async ({ who, locale, read }) => { + const host = makeHost([EDITED_REPORT], who); + expect((await host[read]('report', REPORT, locale))?.label).toBe(EDITED_REPORT_LABEL); + }); + + it.each(cells())('$read read, $who, $locale: the unedited description stays translated', async ({ who, locale, read }) => { + const host = makeHost([EDITED_REPORT], who); + const expected = locale === 'zh-CN' ? '按阶段汇总的商机' : 'Summary of opportunities grouped by stage'; + expect((await host[read]('report', REPORT, locale))?.description).toBe(expected); + }); + + it.each(cells())('$read read, $who, $locale: after a reset the shipped label is served, translated', async ({ who, locale, read }) => { + const host = makeHost([], who); + expect((await host[read]('report', REPORT, locale))?.label).toBe(locale === 'zh-CN' ? '各阶段商机' : SHIPPED_REPORT_LABEL); + }); +}); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 176c8a2d23b..e10e8372deb 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2800,11 +2800,12 @@ export function metaTranslateOptions( * * - `object` — a code `objectExtensions` scalar or a tenant rename folds onto * the owner's declaration (`getPackagedObjectBase`, pre-fold); - * - `dashboard` and `view` — ADR-0126 Regime O: a packaged item is - * overlay-editable, and a published org overlay is what every read serves - * (`getPackagedDashboardBase`, `getPackagedViewBase`). A view is looked up - * by the served item's registry name, the qualified `.` — - * never the bare key the catalog addresses it by under its object. + * - `dashboard`, `view` and `report` — ADR-0126 Regime O: a packaged item + * is overlay-editable, and a published org overlay is what every read + * serves (`getPackagedDashboardBase`, `getPackagedViewBase`, + * `getPackagedReportBase`). A view is looked up by the served item's + * registry name, the qualified `.` — never the bare key + * the catalog addresses it by under its object. * * A translatable type without a row (`action`, `app`, `dataset`, `page`) is * ADR-0126 tier B — a write against its packaged item answers @@ -2818,14 +2819,16 @@ const PACKAGED_BASE_ACCESSORS: ReadonlyMap = new Map; /** - * This request's protocol, for the packaged base of an object, dashboard - * or view ({@link packagedObjectBaseOf}); `undefined` when unreachable. + * This request's protocol, for the packaged base of an object, dashboard, + * view or report ({@link packagedObjectBaseOf}); `undefined` when unreachable. */ resolveProtocol(): Promise; /** @@ -2899,9 +2902,9 @@ export async function translateMetaList( if (!locale) return items; const { translateMetadataDocument } = await import('@objectstack/spec/system'); // [#8284 · #20730] One protocol resolution for the whole page, for every - // type with a packaged base (object, dashboard, view); the lookup itself is - // a synchronous in-memory registry read per element, by that element's own - // registry name — a view's qualified `.`. + // type with a packaged base (object, dashboard, view, report); the lookup + // itself is a synchronous in-memory registry read per element, by that + // element's own registry name — a view's qualified `.`. const p = PACKAGED_BASE_ACCESSORS.has(metaType) ? await sources.resolveProtocol() : undefined; // `getMetaItems` elements are metadata documents (the list envelope is the // OUTER `{ type, items }`), so every element translates directly (#5563). @@ -2926,7 +2929,7 @@ export async function translateMetaList( * labels live on the document. A missing bundle is not a bail-out (the * built-in fallbacks still apply); no locale is. The i18n service is resolved * only for a translatable type, and the protocol only for a type with a - * packaged base — an object, a dashboard or a view (#8284, #20730: + * packaged base — an object, a dashboard, a view or a report (#8284, #20730, #25788: * {@link packagedObjectBaseOf}). * * Moved here, unchanged, from `RestServer.translateMetaItem` (which now diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index b0b486e54ab..4c6a210b1a9 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -175,6 +175,8 @@ const STORE: Record = { // [#25652] Translated by `translatePermissionSet`, as a position is by // `translatePosition` — both are cells of the translatable-type axis. permission: [{ name: 'crm_sales_user', label: 'Sales user', objects: {} }], + // [#25788] Translated by `translateReport` — a cell of the translatable-type axis. + report: [{ name: 'opps_by_stage', label: 'Opportunities by Stage', type: 'summary', dataset: 'pipeline', values: ['amount'] }], }; /** @@ -223,6 +225,7 @@ const BUNDLE: Record = { pages: { home: { label: '首页' } }, positions: { sales_rep: { label: '销售代表' } }, permissions: { crm_sales_user: { label: '销售用户' } }, + reports: { opps_by_stage: { label: '各阶段商机' } }, }, }; @@ -517,7 +520,7 @@ const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is str const TYPE_CELLS: readonly string[] = [ 'app', 'apps', 'view', 'views', 'doc', 'docs', 'book', 'books', 'api', 'apis', 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'picklist', 'flow', - 'position', 'positions', 'permission', 'permissions', + 'position', 'positions', 'permission', 'permissions', 'report', 'reports', // [#20408] A segment that names no metadata type: `RestServer` refuses it // (`refuseUnknownMetaListType`) rather than listing an empty collection. 'totally_invented_type', @@ -682,6 +685,9 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ expect(zhPositions.items?.find((p: any) => p.name === 'sales_rep')?.label).toBe('销售代表'); const zhSets = await read('/meta/permission', {}, { 'accept-language': 'zh-CN' }); expect(zhSets.items?.find((p: any) => p.name === 'crm_sales_user')?.label).toBe('销售用户'); + // [#25788] So is a report — the list the console's report index reads. + const zhReports = await read('/meta/reports', {}, { 'accept-language': 'zh-CN' }); + expect(zhReports.items?.find((r: any) => r.name === 'opps_by_stage')?.label).toBe('各阶段商机'); }); }); @@ -852,7 +858,7 @@ const ITEM_CELLS: readonly string[] = [ '/meta/object/invoice', '/meta/objects/invoice', '/meta/dashboard/ops', '/meta/dashboards/ops', '/meta/page/home', '/meta/action/close_case', '/meta/dataset/pipeline', '/meta/picklist/industry', '/meta/flow/on_lead', '/meta/api/crm_served', - '/meta/position/sales_rep', '/meta/permission/crm_sales_user', + '/meta/position/sales_rep', '/meta/permission/crm_sales_user', '/meta/report/opps_by_stage', ]; const ITEM_TYPE_AXIS = new Set(ITEM_CELLS.map((c) => singular(c.split('/')[2]))); const ITEM_CALLERS = ['holder', 'non-holder', 'builder', 'author'] as const satisfies readonly CallerName[]; @@ -998,6 +1004,7 @@ describe('[#20408] the item read: the reference moves — each probe changes Res expect((await read('/meta/app/crm', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('客户管理'); expect((await read('/meta/position/sales_rep', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售代表'); expect((await read('/meta/permission/crm_sales_user', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售用户'); + expect((await read('/meta/report/opps_by_stage', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('各阶段商机'); const zhDoc = await read('/meta/doc/crm_intro', { locale: 'zh-CN' }); expect(zhDoc.item?.label).toBe('入门'); expect(text(zhDoc)).not.toContain('"translations"'); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 7c5037012a9..a4ab8b99755 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)", + "ReportLike (interface)", "ResolveOptions (interface)", "ResolvedBook (interface)", "ResolvedBookSchema (const)", @@ -895,6 +896,7 @@ "translatePermissionSet (function)", "translatePicklist (function)", "translatePosition (function)", + "translateReport (function)", "translateView (function)", "validationMessageTranslationKey (function)", "walkAddressedPageComponents (function)" diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 0efa26ab8ff..cf6f38d8607 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -912,6 +912,7 @@ "system/PlatformTranslationData:permissions", "system/PlatformTranslationData:picklists", "system/PlatformTranslationData:positions", + "system/PlatformTranslationData:reports", "system/PlatformTranslationData:settings", "system/PlatformTranslationData:settingsCommon", "system/PresignedUrlConfig:contentType", @@ -1328,6 +1329,7 @@ "system/TranslationData:permissions", "system/TranslationData:picklists", "system/TranslationData:positions", + "system/TranslationData:reports", "system/TranslationData:settingsCommon", "system/TranslationDiffItem:aiConfidence", "system/TranslationDiffItem:aiSuggested", @@ -1358,6 +1360,7 @@ "system/TranslationItem:permissions", "system/TranslationItem:picklists", "system/TranslationItem:positions", + "system/TranslationItem:reports", "system/TranslationItem:settingsCommon", "system/VectorClock:clock", "system/WorkerStats:active", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 0f3daef7345..f7ed892e55c 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)", + "ReportLike": "src/system/i18n-resolver.ts#ReportLike (interface)", "ResolveOptions": "src/system/i18n-resolver.ts#ResolveOptions (interface)", "ResolvedBook": "src/system/book.zod.ts#ResolvedBook (interface)", "ResolvedBookSchema": "src/system/book.zod.ts#ResolvedBookSchema (const)", @@ -856,6 +857,7 @@ "translatePermissionSet": "src/system/i18n-resolver.ts#translatePermissionSet (function)", "translatePicklist": "src/system/i18n-resolver.ts#translatePicklist (function)", "translatePosition": "src/system/i18n-resolver.ts#translatePosition (function)", + "translateReport": "src/system/i18n-resolver.ts#translateReport (function)", "translateView": "src/system/i18n-resolver.ts#translateView (function)", "validationMessageTranslationKey": "src/system/validation-message.ts#validationMessageTranslationKey (function)", "walkAddressedPageComponents": "src/system/i18n-resolver.ts#walkAddressedPageComponents (function)" diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index fff1d8a09d6..bba0cf8b57d 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` | 34 | 0 | 0 | 0 | 0 | 34 | +| `translation` | 36 | 0 | 0 | 0 | 0 | 36 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 980cb1034c6..60d2ac78699 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -106,6 +106,26 @@ } } }, + "reports": { + "status": "live", + "verifiedAt": "2026-10-25", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateReport (`reports..label` / `.description`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/rest/src/meta-item-read-gate.ts#translateMetaList (the /meta list read that applies it per request locale)", + "note": "Seeded LIVE with its reader in the same change (the datasets precedent): a served `report` document is localized at the REST boundary, and that list is what the console's report index and report page read (`GET /meta/report`). A report is overlay-editable (ADR-0126 Regime O), so the catalog yields to a label or description an org re-authored on the packaged report (ADR-0029 D9.2a; the packaged base comes from `getPackagedReportBase` on the metadata protocol). The face stops at the two strings the report itself authors.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-25", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupReportAttr (`reports..label`, read by translateReport)", + "note": "Overlays the served report's own `label`." + }, + "description": { + "status": "live", + "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`." + } + } + }, "pages": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/system/i18n-report.test.ts b/packages/spec/src/system/i18n-report.test.ts new file mode 100644 index 00000000000..fa539514410 --- /dev/null +++ b/packages/spec/src/system/i18n-report.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `reports..…` — the report translation group, and the translator that +// overlays it on the served `report` document. +// +// Before this group existed, an app authored in one language listed every +// report in that language on the console's report index: the bundle had no +// key for a report, so `os validate` refused one (`reports` was a retired +// object-first dialect key) and `os i18n check` could not report the gap. + +import { describe, it, expect } from 'vitest'; +import { + LEGACY_OBJECT_FIRST_KEYS, + TranslationDataSchema, + TranslationItemSchema, + PlatformTranslationDataSchema, + type TranslationBundle, +} from './translation.zod'; +import { + translateMetadataDocument, + translateReport, + TRANSLATABLE_METADATA_TYPES, +} from './i18n-resolver'; +// The fixture is 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 report = () => ReportSchema.parse({ + name: 'opps_by_stage', + label: 'Opportunities by Stage', + description: 'Summary of opportunities grouped by stage', + type: 'summary', + dataset: 'pipeline', + rows: ['stage'], + values: ['amount'], +}) as any; + +const bundle: TranslationBundle = { + 'zh-CN': { + reports: { opps_by_stage: { label: '各阶段商机', description: '按阶段汇总的商机' } }, + }, +}; + +describe('TranslationDataSchema — reports', () => { + it('accepts the group 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('is no longer a retired object-first key, so the authored-row sync loads a row that carries it', () => { + // `authored-translation-sync` skips every stored row carrying one of these + // keys; a declared group on that list would never load from Studio. + expect(LEGACY_OBJECT_FIRST_KEYS).not.toContain('reports'); + }); + + it('closes the face at `label` and `description`', () => { + for (const door of [TranslationDataSchema, PlatformTranslationDataSchema]) { + const result = door.safeParse({ reports: { opps_by_stage: { dataset: 'pipeline' } } }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + const item = TranslationItemSchema.safeParse({ locale: 'en', reports: { opps_by_stage: { rows: ['stage'] } } }); + expect(item.success).toBe(false); + expect(item.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('refuses a bare string per report — the value is an object of `label` / `description`', () => { + const result = TranslationDataSchema.safeParse({ reports: { opps_by_stage: '各阶段商机' } }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]).toMatchObject({ code: 'invalid_type', path: ['reports', 'opps_by_stage'] }); + }); + + it('renames the singular spelling onto the group, and `name` / `title` onto `label`', () => { + const issueOf = (result: { success: boolean; error?: { issues: any[] } }) => { + expect(result.success).toBe(false); + return result.error!.issues[0]; + }; + for (const result of [ + TranslationDataSchema.safeParse({ report: { r: { label: 'x' } } }), + TranslationItemSchema.safeParse({ locale: 'en', report: { r: { label: 'x' } } }), + ]) { + const issue = issueOf(result); + expect(issue).toMatchObject({ code: 'unrecognized_keys', keys: ['report'], path: [] }); + expect(issue.message).toContain('`report` → `reports`'); + } + for (const key of ['title', 'name']) { + const issue = issueOf(TranslationDataSchema.safeParse({ reports: { r: { [key]: 'x' } } })); + expect(issue).toMatchObject({ code: 'unrecognized_keys', keys: [key], path: ['reports', 'r'] }); + expect(issue.message).toContain(`\`${key}\` → \`label\``); + } + }); +}); + +describe('translateReport', () => { + it('is registered in the dispatch table, so the REST boundary follows', () => { + // `TRANSLATABLE_METADATA_TYPES` is DERIVED from the dispatch table and + // `@objectstack/rest` reads it: `GET /meta/report` (list and item) is + // localized with nothing else to remember. + expect(TRANSLATABLE_METADATA_TYPES.has('report')).toBe(true); + }); + + it('overlays the label and the description', () => { + const out = translateMetadataDocument('report', report(), bundle, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('各阶段商机'); + expect(out.description).toBe('按阶段汇总的商机'); + // Identity and the selection ride through untouched. + expect(out.name).toBe('opps_by_stage'); + expect(out.dataset).toBe('pipeline'); + expect(out.values).toEqual(['amount']); + }); + + it('keeps an unaddressed description as authored', () => { + const partial: TranslationBundle = { 'zh-CN': { reports: { opps_by_stage: { label: '各阶段商机' } } } }; + const out = translateReport(report(), partial, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('各阶段商机'); + expect(out.description).toBe('Summary of opportunities grouped by stage'); + }); + + it('reads the report group only — a dashboard of the same name translates nothing here', () => { + const shared: TranslationBundle = { 'zh-CN': { dashboards: { opps_by_stage: { label: '仪表盘' } } } }; + const doc = report(); + expect(translateReport(doc, shared, { locale: 'zh-CN' })).toBe(doc); + }); + + it('applies the BCP-47 ladder and the fallback chain', () => { + expect((translateReport(report(), bundle, { locale: 'zh' }) as any).label).toBe('各阶段商机'); + expect((translateReport(report(), bundle, { locale: 'fr-FR', fallbackChain: ['zh-CN'] }) as any).label) + .toBe('各阶段商机'); + }); + + it('leaves an inline locale map intact when the bundle does not address it', () => { + const inline = ReportSchema.parse({ + name: 'leads_by_source', + label: { en: 'Leads by Source', 'zh-CN': '线索来源' }, + type: 'summary', + dataset: 'leads', + values: ['n'], + }) as any; + expect(translateReport(inline, bundle, { locale: 'zh-CN' })).toBe(inline); + }); + + it('does not mutate the input, and returns the same reference when nothing matched', () => { + const doc = report(); + const before = JSON.parse(JSON.stringify(doc)); + translateReport(doc, bundle, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + expect(translateReport(doc, bundle, { locale: 'ja-JP' })).toBe(doc); + expect(translateReport(doc, undefined, { locale: 'zh-CN' })).toBe(doc); + const anonymous = { label: 'x' } as any; + expect(translateReport(anonymous, bundle, { locale: 'zh-CN' })).toBe(anonymous); + }); + + describe('the catalog loses to an explicit override (ADR-0029 D9.2a)', () => { + const packagedBase = () => report(); + + it('applies while the served strings still equal the packaged ones', () => { + const out = translateReport(report(), bundle, { locale: 'zh-CN', packagedBase: packagedBase() }) as any; + expect(out.label).toBe('各阶段商机'); + expect(out.description).toBe('按阶段汇总的商机'); + }); + + it('yields to a label an org re-authored, and still translates the unedited description', () => { + const edited = { ...report(), label: 'Pipeline by stage (ours)' }; + const out = translateReport(edited, bundle, { locale: 'zh-CN', packagedBase: packagedBase() }) as any; + expect(out.label).toBe('Pipeline by stage (ours)'); + expect(out.description).toBe('按阶段汇总的商机'); + }); + + it('returns the same reference when both strings were re-authored', () => { + const edited = { ...report(), label: 'Ours', description: 'Our description' }; + expect(translateReport(edited, bundle, { locale: 'zh-CN', packagedBase: packagedBase() })).toBe(edited); + }); + + it('applies with no base supplied — a runtime-authored report has none', () => { + const edited = { ...report(), label: 'Pipeline by stage (ours)' }; + expect((translateReport(edited, bundle, { locale: 'zh-CN' }) as any).label).toBe('各阶段商机'); + }); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 6dafcfe3b09..77823c09515 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1505,6 +1505,9 @@ const METADATA_DOCUMENT_TRANSLATORS: Record< // `GET /meta/permission`, so registering here is the whole wiring. position: translatePosition, permission: translatePermissionSet, + // The console's report index and report page both read `GET /meta/report`, + // so registering here is the whole wiring. + report: translateReport, }; /** @@ -2219,6 +2222,84 @@ export function translatePermissionSet( return translateSecurityCatalogItem('permissions', doc, bundle, opts); } +// ──────────────────────────────────────────────────────────────────────────── +// Report metadata resolver (label + description) +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal shape of a report document consumed by {@link translateReport}. */ +export interface ReportLike { + name: string; + label?: unknown; + description?: unknown; + [key: string]: any; +} + +function lookupReportAttr( + bundle: TranslationBundle | undefined, + name: 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]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return 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). + * + * 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. + * + * ## The catalog loses to an explicit override — ADR-0029 D9.2a + * + * A report is overlay-editable (ADR-0126 Regime O, beside view and + * dashboard): an org may re-author a packaged report's `label`, and that + * overlay is what every read serves. The catalog is the packaged translation + * of the PACKAGED declaration, so each string here follows the rule + * {@link translateDashboard} follows: it applies only while the served value + * still equals the one in {@link TranslateDocumentOptions.packagedBase}, which + * the serving layer supplies (`getPackagedReportBase` on the metadata + * protocol). No base supplied — a runtime-authored report, or a caller that + * cannot see one — means the catalog applies. + * + * `label` and `description` are `I18nLabelSchema` at the authoring site, so + * either may be an inline locale map; a key the bundle does not carry leaves + * that map intact rather than flattening it to one language. + */ +export function translateReport( + doc: T, + bundle: TranslationBundle | undefined, + opts?: TranslateDocumentOptions, +): T { + if (!doc || typeof doc !== 'object') return doc; + const name = doc.name; + if (typeof name !== 'string' || name.length === 0 || !bundle) return doc; + + const base = asRecord(opts?.packagedBase); + const label = valueOverridesPackagedBase(base, 'label', doc.label) + ? undefined + : lookupReportAttr(bundle, name, 'label', opts); + const description = valueOverridesPackagedBase(base, 'description', doc.description) + ? undefined + : lookupReportAttr(bundle, name, 'description', opts); + if (label === undefined && description === undefined) return doc; + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + }; +} + // ──────────────────────────────────────────────────────────────────────────── // Page metadata resolvers (label / description / page:header title + subtitle) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index f5443c49197..53eacbe2ea0 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -571,13 +571,21 @@ export type ObjectTranslationData = z.input; * scans rows that were saved *before* this shape was closed and warns rather * than loading them, since nothing resolves from them. New authoring is handled * by {@link TRANSLATION_KEY_GUIDANCE} on the strict shapes below. + * + * `reports` left this list when it became a declared group (#25788, + * `reports..label` / `.description`): a key the shape declares + * cannot also be a retired dialect key, or the sync would skip every row that + * translates a report. A row stored before #3778 that carried the retired + * `reports` key with no other key of this list now merges like any other row: + * the dialect's measured footprint was zero (#3778), a value at + * `reports..label` / `.description` now translates that report, and any + * other value under it still renders nothing. */ export const LEGACY_OBJECT_FIRST_KEYS = [ 'o', 'app', 'nav', 'dashboard', - 'reports', 'notifications', 'errors', '_globalOptions', @@ -647,7 +655,6 @@ const TRANSLATION_KEY_GUIDANCE: Record'", nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'", dashboard: "`dashboard` is the retired object-first dialect, which no resolver reads — use 'dashboards.' (plural)", - reports: '`reports` is the retired object-first dialect — reports have no translation group, omit them', notifications: '`notifications` is the retired object-first dialect — notifications have no translation group, omit them', // Was: "use 'validationMessages' for rule messages". That was a signpost to a // key with no reader — #3778 retired `errors` by pointing authors at @@ -685,9 +692,10 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + 'application\'s own copy use the thirteen groups this bundle does declare, in the order it ' + + 'application\'s own copy use the fourteen groups this bundle does declare, in the order it ' + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " - + "'datasets', 'pages', 'flows', 'positions', 'permissions', 'metadataForms', 'settingsCommon'. " + + "'datasets', 'reports', 'pages', 'flows', 'positions', 'permissions', 'metadataForms', " + + "'settingsCommon'. " + "Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' + 'badges, under `settingsCommon.sourceLabels`) are NOT what is being refused here — only the ' @@ -729,7 +737,7 @@ const ITEM_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than overriding it from an application. For an ' - + 'application\'s own copy use the groups this item does declare — the same thirteen a per-app ' + + 'application\'s own copy use the groups this item does declare — the same fourteen a per-app ' + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + "refused here — only the per-namespace manifest copy under 'settings' is. " @@ -768,12 +776,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Thirteen groups — the fourteenth, `settings`, is platform-only and lives in + * schema. Fourteen groups — the fifteenth, `settings`, is platform-only and lives in * {@link platformSettingsShape}. * * Three schemas need exactly these keys: {@link TranslationDataSchema} (one - * entry of a per-app file-authored bundle — these thirteen and no more), - * {@link PlatformTranslationDataSchema} (these thirteen plus `settings`) and + * entry of a per-app file-authored bundle — these fourteen and no more), + * {@link PlatformTranslationDataSchema} (these fourteen plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — * the per-app face plus `locale`, its identity keys and the ADR-0010 * envelope; it carried the platform face's `settings` too until #19620). The item used to @@ -1100,6 +1108,38 @@ const appTranslationDataShape = () => ({ })).optional().describe('Measure translations keyed by measure name (`DatasetMeasureSchema.name`)'), })).optional().describe('Analytics dataset translations keyed by dataset name'), + /** + * Report translations keyed by report name (`Report.name`, + * `ui/report.zod.ts`, the stack's `reports[]`). + * + * reports..label → the report's own `label` + * reports..description → the report's own `description` + * + * The two strings the console's report index lists each report by, and the + * report page's heading. Overlaid by `translateReport` (`i18n-resolver.ts`) + * on every served `report` document (`GET /meta/report`, the list both the + * index and the page read). A report is overlay-editable (ADR-0126 Regime + * 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.`. + * + * ⚠️ `label` and `description` are `I18nLabelSchema` at the authoring site, + * so a report's copy may already be an inline `{ en, 'zh-CN' }` map. + * `translateReport` writes ONLY where the bundle has an entry, so an inline + * map the bundle does not cover is left intact. + */ + reports: z.record(z.string(), strictObject({ + surface: 'this report translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label' }, + }, { + 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`"), + })).optional().describe('Report translations keyed by report name'), + /** * Page translations keyed by page name (`Page.name`). * @@ -1874,10 +1914,10 @@ const platformSettingsShape = () => ({ * One locale of a PER-APP translation bundle — `stack.translations`, and * everything {@link defineTranslationBundle} builds. * - * Thirteen groups: every group the platform bundle declares EXCEPT `settings`, + * Fourteen groups: every group the platform bundle declares EXCEPT `settings`, * which is platform-only and is refused here by name with * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See - * {@link PlatformTranslationDataSchema} for the fourteen-group face and for why + * {@link PlatformTranslationDataSchema} for the fifteen-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -1888,7 +1928,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ // `settings`, and an alias prescribing a key the shape rejects is a // suggestion the author cannot take (the `alias-integrity` audit judges // exactly that). Both spellings are answered by `guidance` above instead. - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -1898,7 +1938,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the fourteen groups, `settings` + * One locale of a PLATFORM translation bundle — the fifteen groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -1933,7 +1973,7 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the platform translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -2049,7 +2089,7 @@ export type TranslationConfig = z.input; * to whoever — or whatever — authored it. * * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): - * the item takes the PER-APP face, the same thirteen groups as + * the item takes the PER-APP face, the same fourteen groups as * {@link TranslationDataSchema}, and refuses `settings` (and the singular * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. * The file door and the item door are two authoring surfaces for one app @@ -2088,7 +2128,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // this door no longer declares `settings`, and an alias prescribing a key // the shape rejects is a suggestion the author cannot take. Both spellings // are answered by `guidance` above instead. - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index 6933a991b73..b68f688d516 100644 --- a/scripts/fixtures/i18n-walk-parity/every-group.stack.json +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -123,6 +123,9 @@ ] } ], + "reports": [ + { "name": "parity_report", "label": "Parity Report", "description": "Records by status", "type": "summary", "dataset": "parity_metrics", "rows": ["status"], "values": ["record_count"] } + ], "picklists": [ { diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 70a8206142a..1555759c7e3 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,7 +13,7 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-25", + "generatedAt": "2026-10-26", "sha256": "1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7", "components": 106, "inTree": { @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "35ef1abd88b0011a06bfc895b3091d1f8ecf319c3f78d2bfb0d9feb4e53f3bbe" + "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" } } diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index e617ad58426..08c1eca5a0f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1841,6 +1841,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.translationPreview.category.globalActions': 'Global Actions', 'engine.translationPreview.category.dashboards': 'Dashboards', 'engine.translationPreview.category.datasets': 'Datasets', + 'engine.translationPreview.category.reports': 'Reports', 'engine.translationPreview.category.pages': 'Pages', 'engine.translationPreview.category.flows': 'Flows', 'engine.translationPreview.category.positions': 'Positions', @@ -5522,6 +5523,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.translationPreview.category.globalActions': '全局操作', 'engine.translationPreview.category.dashboards': '仪表盘', 'engine.translationPreview.category.datasets': '数据集', + 'engine.translationPreview.category.reports': '报表', 'engine.translationPreview.category.pages': '页面', 'engine.translationPreview.category.flows': '流程', 'engine.translationPreview.category.positions': '岗位', diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx index f595f7c9fd8..26ca72b5c35 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx @@ -56,6 +56,11 @@ const ONE_ENTRY: Record; key: string; ro key: 'revenue', row: () => keys(2), }, + reports: { + data: { opps_by_stage: { label: 'Opportunités par étape', description: 'Opportunités regroupées par étape' } }, + key: 'opps_by_stage', + row: () => keys(2), + }, pages: { data: { home: { label: 'Accueil' } }, key: 'home', row: () => keys(1) }, flows: { data: { onboarding: { label: 'Intégration' } }, key: 'onboarding', row: () => keys(1) }, positions: { data: { legal_counsel: { label: 'Conseiller juridique' } }, key: 'legal_counsel', row: () => keys(1) }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx index f46d43302a9..d9fe855240a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx @@ -32,6 +32,7 @@ import * as React from 'react'; import { AppWindow, + BarChart3, ClipboardList, Database, FileText, @@ -94,6 +95,7 @@ const CATEGORY_DEFS = { }, dashboards: { label: 'engine.translationPreview.category.dashboards', icon: LayoutDashboard, flat: false }, datasets: { label: 'engine.translationPreview.category.datasets', icon: Database, flat: false }, + reports: { label: 'engine.translationPreview.category.reports', icon: BarChart3, flat: false }, pages: { label: 'engine.translationPreview.category.pages', icon: PanelsTopLeft, flat: false }, flows: { label: 'engine.translationPreview.category.flows', icon: Workflow, flat: false }, positions: { label: 'engine.translationPreview.category.positions', icon: Users, flat: false }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx index 0b8aeda0ed6..26a8c8eccd4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx @@ -459,6 +459,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.category.globalActions' }, { key: 'engine.translationPreview.category.dashboards' }, { key: 'engine.translationPreview.category.datasets' }, + { key: 'engine.translationPreview.category.reports' }, { key: 'engine.translationPreview.category.pages' }, { key: 'engine.translationPreview.category.flows' }, { key: 'engine.translationPreview.category.positions' }, @@ -470,7 +471,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.keyCountOne', vars: { count: 1 } }, ]); // The bundle's own locale is author data, not the designer's language. - expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/13 (15%)']); + expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/14 (14%)']); }); } }); diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index 98b3e5b291e..be3c8c318c7 100644 --- a/ui/packages/i18n/src/utils/spec-translations.ts +++ b/ui/packages/i18n/src/utils/spec-translations.ts @@ -94,6 +94,9 @@ const SPEC_TRANSLATION_GROUPS = [ // commit (objectui#13478), copied through onto `app` the same way. 'positions', 'permissions', + // objectstack #25788 serves `reports` (a report's label and description are + // translatable), copied through onto `app` the same way. + 'reports', ] as const; function isPlainObject(value: unknown): value is Record {