From d2fc4e8558954bb323a2071063c192569133e99f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:50:39 +0000 Subject: [PATCH 1/7] wip(i18n): widen the shared page walk to slots + items[].children; add dashboards.*.globalFilters Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../i18n-slotted-pages-and-global-filters.md | 15 + content/docs/ui/translations.mdx | 4 +- packages/cli/src/utils/i18n-extract.ts | 37 +- .../test/platform-page-i18n-parity.test.ts | 88 +++- packages/spec/liveness/translation.json | 2 +- .../spec/src/system/i18n-resolver.test.ts | 412 +++++++++++++++++- packages/spec/src/system/i18n-resolver.ts | 366 +++++++++++++--- packages/spec/src/system/translation.test.ts | 64 ++- packages/spec/src/system/translation.zod.ts | 54 ++- 9 files changed, 935 insertions(+), 107 deletions(-) create mode 100644 .changeset/i18n-slotted-pages-and-global-filters.md diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md new file mode 100644 index 0000000000..5bbf10893b --- /dev/null +++ b/.changeset/i18n-slotted-pages-and-global-filters.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +--- + +Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + +**`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + +- Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. +- `translatePage` carries the rebuilt `slots` back onto the document. + +**`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + +**`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 1064f4fdc7..bdc44f6324 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -74,9 +74,11 @@ export default defineStack({ | App navigation | `apps..navigation..label` | | Dashboard label / description | `dashboards..label` / `description` | | Dashboard widget title / description / sub-caption | `dashboards..widgets..title` / `description` / `subCaption` | +| 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` | -| Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` | +| 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` | | Screen-flow wizards (flow label, screen headings, screen field copy) | `flows..label` / `flows..screens..title` / `.fields..label` / `.placeholder` — see the boundary note below | | Global actions, settings, messages | `globalActions`, `settings`, `messages` | | 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 | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 2bb6acd745..eb16d97f3d 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -127,6 +127,7 @@ import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, + globalFilterKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; @@ -1321,6 +1322,26 @@ export function collectExpectedEntries( pushEntry(out, ['dashboards', name, 'widgets', wid, 'description'], w.description, 'widget'); } } + // Global-filter copy (#16772) — `dashboards..globalFilters.`, + // the filter bar drawn above the widget titles. The KEY is imported from + // `@objectstack/spec` (`name`, else `field`) so the extractor offers the + // entry `translateDashboard` reads and never a neighbour of it; an option + // is keyed by its `value` spelled as a string, the resolver's own + // spelling. `optionsFrom` options are fetched rows and have no key. + const globalFilters: any[] = Array.isArray(dash.globalFilters) ? dash.globalFilters : []; + for (const filter of globalFilters) { + if (!filter || typeof filter !== 'object') continue; + const key = globalFilterKey(filter); + if (key === undefined) continue; + pushEntry(out, ['dashboards', name, 'globalFilters', key, 'label'], filter.label, 'dashboard'); + const options: any[] = Array.isArray(filter.options) ? filter.options : []; + for (const option of options) { + if (!option || typeof option !== 'object') continue; + const { value } = option; + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') continue; + pushEntry(out, ['dashboards', name, 'globalFilters', key, 'options', String(value)], option.label, 'dashboard'); + } + } } // ── Analytics datasets (`datasets..…`) ───────────────────── @@ -1337,12 +1358,13 @@ export function collectExpectedEntries( } // Header copy is authored inside the page's `page:header` component but // is addressed by page name — `translatePage` overlays it back onto every - // header in the page's regions. - const regions: any[] = Array.isArray(page.regions) ? page.regions : []; - for (const region of regions) { - const components: any[] = Array.isArray(region?.components) ? region.components : []; - for (const component of components) { - if (component?.type !== PAGE_HEADER_COMPONENT_TYPE) continue; + // ROOT-LEVEL header: a region's entry, or a `slots.` entry on a + // `kind: 'slotted'` page (#16772). Which components are root level is the + // shared walk's to say (`nested: false`), not a second loop's — the loop + // this replaced read `page.regions` by hand and would have offered + // nothing for the `slots.header` the resolver now translates. + walkAddressedPageComponents(page, (component, { nested }) => { + if (!nested && component?.type === PAGE_HEADER_COMPONENT_TYPE) { const props = component.properties ?? {}; // `title` duplicating `label` is the common case and resolves via the // label fallback — only emit it when the two genuinely differ. @@ -1351,7 +1373,8 @@ export function collectExpectedEntries( } pushEntry(out, ['pages', name, 'subtitle'], props.subtitle, 'page'); } - } + return component; + }); // Per-component copy, addressed by the component's own id (#6080). Without // this pass the face exists but nothing writes the skeleton, so a diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index ac4ec32de8..70820613d8 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -289,18 +289,24 @@ const walkParityPage = (): Record => ({ null, ], // NOT descended by `translatePage`: `body`/`footer` are a - // renderer-side back-compat fallback, and `items[].children` sits - // one level deeper than the slot the ruling names. + // renderer-side back-compat fallback, not an authorable + // composition spelling. body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }], footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }], - items: [{ children: [{ id: 'tab_child', type: 'object-metric', properties: { title: 'Tab child' } }] }], + // DESCENDED since #16772 — a `page:tabs` / `page:accordion` + // panel's `items[].children`, one level below the container. + items: [{ label: 'Panel', children: [{ id: 'tab_child', type: 'object-metric', properties: { title: 'Tab child' } }] }], }, }, ], }, ], - // NOT walked by `translatePage` at all — it maps `regions` only. - slots: { aside: { id: 'slot_child', type: 'object-metric', properties: { title: 'Slot child' } } }, + // A ROOT since #16772 — a `kind: 'slotted'` page authors its components + // here (one component or an array per slot); walked after the regions. + slots: { + aside: { id: 'slot_child', type: 'object-metric', properties: { title: 'Slot child' } }, + details: [{ id: 'slot_list_child', type: 'record:details', properties: { title: 'Slot list child' } }], + }, }); /** A container chain deeper than the resolver's descent cap. */ @@ -370,17 +376,46 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { const page = walkParityPage(); expect([...idsExtractorOffers(page)].sort()).toEqual([ 'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', + 'slot_child', 'slot_list_child', 'tab_child', ]); - // `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are - // absent from BOTH sides — the shapes `translatePage` does not descend. + // `card_body_child` and `card_footer_child` are absent from BOTH sides — + // the shapes `translatePage` does not descend. `tab_child`, `slot_child` + // and `slot_list_child` are present on BOTH sides since #16772 widened + // the shared walk to `items[].children` and to the `slots.` roots. // `hdr` — the region-level `page:header` — is absent from BOTH sides since // the ruling. `nested_header` stays: a `page:header` inside a container is // reached by the id route only, so the id key is the only key it has. expect([...idsResolverApplies(page)].sort()).toEqual([ 'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', + 'slot_child', 'slot_list_child', 'tab_child', ]); }); + it('offers and reads the page-name header route for a `slots.header` page:header — a slotted page has a header too (#16772)', () => { + const page = { + name: 'slotted_header_page', + label: 'Contract', + kind: 'slotted', + regions: [], + slots: { + header: { id: 'hdr', type: 'page:header', properties: { title: 'Contract detail', subtitle: 'Lifecycle' } }, + }, + }; + const offered = collectExpectedEntries({ pages: [page] } as any) + .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name) + .map((e) => e.path.slice(2).join('.')) + .sort(); + // Page-name route offered; the id route NOT offered for a root-level + // header, exactly as for a region-level one. + expect(offered).toEqual(['label', 'subtitle', 'title']); + + const bundle = { + en: { pages: { slotted_header_page: { title: 'T::title', subtitle: 'T::subtitle', components: { hdr: { title: 'ID-ROUTE' } } } } }, + } as any; + const out = translatePage(page as any, bundle, { locale: 'en' }); + expect(out.slots.header.properties).toEqual({ title: 'T::title', subtitle: 'T::subtitle' }); + }); + // ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ── // // Maintainer 「同意」, option 1, verbatim: "The page-name route is canonical @@ -756,22 +791,31 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); }); - it('records that the extractor reaches the page label and nothing under `slots`', () => { - // A BOUNDARY PIN, not an endorsement. It states the measured fact that the - // shared walk roots at `regions[].components[]` and these pages author - // `regions: []`, so the 45 inline sites under `slots.*` have no bundle - // face. If the walk is ever widened -- a maintainer decision open on - // #14749 -- this reds, and the person widening it is told, at the exact - // moment they can act on it, that these three pages gain a bundle surface - // that needs entries and a coverage home. That notice is the whole value: - // today the same change would land green over an unmeasured population. + it('records that the extractor now reaches under `slots` — and that every site it reaches there is an inline locale map with no seed', () => { + // A BOUNDARY PIN, not an endorsement — moved, not removed. Until #16772 + // this pinned `offered: ['label']`: the shared walk rooted at + // `regions[].components[]`, these pages author `regions: []`, and the 45 + // inline sites under `slots.*` had no bundle face. #16772 widened the + // walk to the `slots.` roots and to `items[].children`, so the + // notice the old pin promised has fired, and this is the answer to it: + // these pages' copy is authored as inline locale maps (the ruled route for + // page copy, judged complete by the next case), so what the extractor + // offers for them is a set of `inlineLocales` rows — authored-with-no- + // seed, never a string to translate. The bundle surface they gained + // therefore needs NO entries, and their coverage home stays this file. + // What this pin holds: the reach is real (more than the label alone), and + // it exposes no seeded string for a translator to be asked for. for (const page of RECORD_PAGES) { - const offered = collectExpectedEntries({ pages: [page] } as any) - .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name) - .map((e) => e.path.slice(2).join('.')) - .sort(); - expect({ page: page.name, regions: page.regions, offered }) - .toEqual({ page: page.name, regions: [], offered: ['label'] }); + const entries = collectExpectedEntries({ pages: [page] } as any) + .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name); + const offered = entries.map((e) => e.path.slice(2).join('.')).sort(); + expect(page.regions).toEqual([]); + expect(offered).toContain('label'); + expect(offered.filter((k) => k.startsWith('components.')).length).toBeGreaterThan(0); + const seeded = entries + .filter((e) => e.path[2] === 'components' && e.inline !== undefined) + .map((e) => e.path.slice(2).join('.')); + expect({ page: page.name, seededUnderSlots: seeded }).toEqual({ page: page.name, seededUnderSlots: [] }); } }); diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 46c0a67ad7..c2d6a1adde 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -51,7 +51,7 @@ "dashboards": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDashboardAttr (`dashboards..label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupWidgetAttr (`dashboards..widgets..`, `subCaption` included); packages/spec/src/system/i18n-resolver.ts#translateDashboard", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDashboardAttr (`dashboards..label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupWidgetAttr (`dashboards..widgets..`, `subCaption` included); packages/spec/src/system/i18n-resolver.ts#translateGlobalFilter (`dashboards..globalFilters..label` / `.options.`, keyed by `globalFilterKey` — `name`, else `field`; #16772); packages/spec/src/system/i18n-resolver.ts#translateDashboard", "note": "translateDashboard: label/description plus per-widget title/description/subCaption by widget id; header action labels. `subCaption` (#7862, #5428 item 4) overlays the metric widget's `options.description` — a different authored field from `widget.description`, each on its own key — live through the same translateDashboard REST path; objectui's client-side renderer half is the downstream follow-up. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:538` had rotted onto the opening line of `translateAction`'s docblock, an ACTION resolver ~244 lines above the dashboard ones; the second position `:554` was a bare line suffix with no path and resolved to nothing. Re-closed by hand against 8cb96ec41." }, "datasets": { diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index afbb5525fa..d8a96ac5e4 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -902,6 +902,7 @@ describe('resolveMetadataFormLabels', () => { import { translateApp, translateDashboard, + globalFilterKey, resolveViewLabel as _resolveViewLabel, type DashboardLike, } from './i18n-resolver'; @@ -1124,6 +1125,118 @@ describe('translateDashboard', () => { import { translatePage } from './i18n-resolver'; +describe('translateDashboard — global filters (#16772)', () => { + const bundle: TranslationBundle = { + 'zh-CN': { + dashboards: { + contract_overview: { + widgets: { by_category: { title: '按类别' } }, + globalFilters: { + // Keyed by `name` when the filter authors one… + dept: { label: '申请部门', options: { legal: '法务', sales: '销售' } }, + // …and by `field` when it does not (the schema's declared default). + category: { label: '类别', options: { '1': '服务', 'true': '有效' } }, + // CONTROL: this entry is keyed by the FIELD of a filter that + // authors a `name`, so it must never be read. + department: { label: 'MUST-NOT-APPLY' }, + }, + }, + }, + }, + en: { + dashboards: { + contract_overview: { + globalFilters: { dept: { options: { ops: 'Operations' } } }, + }, + }, + }, + }; + + const dashboard = () => ({ + name: 'contract_overview', + label: 'Contract Overview', + widgets: [{ id: 'by_category', title: 'By category' }], + globalFilters: [ + { + name: 'dept', field: 'department', type: 'select', label: 'Requesting Department', + options: [ + { value: 'legal', label: 'Legal' }, + { value: 'sales', label: 'Sales' }, + { value: 'ops', label: 'Ops' }, + { value: 'hr', label: 'HR' }, + ], + }, + { + field: 'category', type: 'select', label: { en: 'Category', 'zh-CN': '类别(内联)' }, + options: [{ value: 1, label: 'Services' }, { value: true, label: 'Active' }, { value: 'x', label: 'Untouched' }], + }, + { field: 'owner', type: 'lookup', label: 'Owner', optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' } }, + ], + }); + + it('translates the filter label and its static option labels, keyed by `name`', () => { + const out = translateDashboard(dashboard(), bundle, { locale: 'zh-CN' }); + expect(out.globalFilters![0].label).toBe('申请部门'); + expect(out.globalFilters![0].options).toEqual([ + { value: 'legal', label: '法务' }, + { value: 'sales', label: '销售' }, + // Resolved key by key along the locale chain — `ops` comes from `en`. + { value: 'ops', label: 'Operations' }, + // No entry anywhere on the chain: authored label kept. + { value: 'hr', label: 'HR' }, + ]); + // The widget half keeps working alongside. + expect(out.widgets![0].title).toBe('按类别'); + }); + + it('keys a filter that authors no `name` by its `field`, and spells number/boolean option values as strings', () => { + const out = translateDashboard(dashboard(), bundle, { locale: 'zh-CN' }); + const category = out.globalFilters![1]; + // A bundle entry wins over an authored inline locale map, as everywhere + // else on this surface. + expect(category.label).toBe('类别'); + expect(category.options).toEqual([ + { value: 1, label: '服务' }, + { value: true, label: '有效' }, + { value: 'x', label: 'Untouched' }, + ]); + }); + + it('CONTROL: a filter with a `name` is not reachable by its `field`, and an unaddressed filter keeps its identity', () => { + const doc = dashboard(); + const out = translateDashboard(doc, bundle, { locale: 'zh-CN' }); + expect(out.globalFilters![0].label).not.toBe('MUST-NOT-APPLY'); + // `owner` has no entry: the very same object comes back, and `optionsFrom` + // is carried through untouched. + expect(out.globalFilters![2]).toBe(doc.globalFilters[2]); + // The input is never mutated. + expect(doc.globalFilters[0].label).toBe('Requesting Department'); + expect(doc.globalFilters[0].options[0].label).toBe('Legal'); + }); + + it('CONTROL: leaves `globalFilters` off the copy when nothing resolved, and invents none on a dashboard without filters', () => { + const filters = dashboard().globalFilters; + const untouched = translateDashboard( + { name: 'other_dashboard', globalFilters: filters }, + bundle, + { locale: 'zh-CN' }, + ); + // Nothing resolved ⇒ the authored array itself is carried through, not a + // rebuilt copy of it. + expect(untouched.globalFilters).toBe(filters); + + const noFilters = translateDashboard({ name: 'contract_overview', label: 'X' }, bundle, { locale: 'zh-CN' }); + expect('globalFilters' in noFilters).toBe(false); + }); + + it('exports the key derivation the extractor shares — `name`, else `field`, else nothing', () => { + expect(globalFilterKey({ name: 'dept', field: 'department' })).toBe('dept'); + expect(globalFilterKey({ field: 'category' })).toBe('category'); + expect(globalFilterKey({ name: '', field: 'category' })).toBe('category'); + expect(globalFilterKey({})).toBeUndefined(); + }); +}); + describe('translatePage', () => { const bundle: TranslationBundle = { 'zh-CN': { @@ -1632,17 +1745,29 @@ describe('translatePage — nested `properties.children` descent (#12961)', () = expect((card(out).properties.body as any[])[0].properties.label).toBe('Deals Won'); }); - it('does not descend into `items[].children` — outside the ruled `properties.children` face', () => { + it('descends into `items[].children` — the contract call the #12961 line left open, made by #16772', () => { // `page:tabs` / `page:accordion` nest their children one level deeper, - // under `properties.items[].children`. The ruling names - // `properties.children`; widening further is its own contract call, so - // this records where the line is rather than silently crossing it. + // under `properties.items[].children`. The #12961 ruling named + // `properties.children` and recorded this one as "its own contract + // call"; #16772 is that call, measured on a slotted contract page whose + // seven tab panels held every related list the resolver never reached. const out = translatePage( nestedUnder({ items: [{ label: 'Details', children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }] }), kpiBundle, { locale: 'zh-CN' }, ); - expect((card(out).properties.items as any[])[0].children[0].properties.label).toBe('Deals Won'); + expect((card(out).properties.items as any[])[0].children[0].properties.label).toBe('赢单数'); + // The panel's own keys survive the rebuild. + expect((card(out).properties.items as any[])[0].label).toBe('Details'); + }); + + it('still does not descend into `footer` — the other back-compat spelling (#5775)', () => { + const out = translatePage( + nestedUnder({ footer: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }), + kpiBundle, + { locale: 'zh-CN' }, + ); + expect((card(out).properties.footer as any[])[0].properties.label).toBe('Deals Won'); }); it('keeps the page-name header route region-level', () => { @@ -1873,15 +1998,59 @@ describe('walkAddressedPageComponents (#13218)', () => { return rows; }; - it('walks regions[].components[] only — slots is not a root', () => { + it('walks regions[].components[] AND slots. as roots — regions first, then slots in authored order (#16772)', () => { + // A slot holds one component OR an array of them (`PageSchema.slots`); + // both shapes are roots, at depth 0 and un-nested, exactly like a + // region's entry. Before #16772 this walk visited `a` alone. const doc: any = { regions: [{ name: 'main', components: [{ id: 'a', type: 'object-metric', properties: {} }] }], - slots: { aside: { id: 'slot_child', type: 'object-metric', properties: {} } }, + slots: { + header: { id: 'slot_header', type: 'page:header', properties: {} }, + details: [ + { id: 'slot_d1', type: 'record:details', properties: {} }, + { id: 'slot_d2', type: 'record:details', properties: {} }, + ], + }, + }; + expect(trace(doc)).toEqual([ + { id: 'a', nested: false, depth: 0, addressed: true }, + { id: 'slot_header', nested: false, depth: 0, addressed: true }, + { id: 'slot_d1', nested: false, depth: 0, addressed: true }, + { id: 'slot_d2', nested: false, depth: 0, addressed: true }, + ]); + }); + + it('visits every root of a `kind: slotted` page that authors `regions: []` — the measured zero (#16772)', () => { + // The shape the card measured: `regions: []`, everything under `slots`. + // `walkAddressedPageComponents(page, …)` visited NOTHING on it, so + // `pages.` carried exactly two addressable keys however many + // components the page authored. + const doc: any = { + kind: 'slotted', + regions: [], + slots: { + highlights: { id: 'path', type: 'record:path', properties: {} }, + tabs: { + id: 'tabs', + type: 'page:tabs', + properties: { + items: [ + { label: 'Overview', children: [{ id: 'rl_1', type: 'record:related_list', properties: {} }] }, + { label: 'History', children: [{ id: 'rl_2', type: 'record:related_list', properties: {} }] }, + ], + }, + }, + }, }; - expect(trace(doc).map((r) => r.id)).toEqual(['a']); + expect(trace(doc)).toEqual([ + { id: 'path', nested: false, depth: 0, addressed: true }, + { id: 'tabs', nested: false, depth: 0, addressed: true }, + { id: 'rl_1', nested: true, depth: 1, addressed: true }, + { id: 'rl_2', nested: true, depth: 1, addressed: true }, + ]); }); - it('descends properties.children only — body, footer and items[].children stay unvisited', () => { + it('descends properties.children AND properties.items[].children — body and footer stay unvisited (#16772)', () => { const doc: any = { regions: [{ name: 'main', @@ -1897,7 +2066,65 @@ describe('walkAddressedPageComponents (#13218)', () => { }], }], }; - expect(trace(doc).map((r) => r.id)).toEqual(['card', 'in_children']); + // `children` first, then the panels — both one level below the container; + // `body` / `footer` are the renderer's back-compat fallback and are still + // not an authorable composition spelling. + expect(trace(doc)).toEqual([ + { id: 'card', nested: false, depth: 0, addressed: true }, + { id: 'in_children', nested: true, depth: 1, addressed: true }, + { id: 'in_items', nested: true, depth: 1, addressed: true }, + ]); + }); + + it('matches a panel by SHAPE — an `items` entry without a `children` array is not descended and passes through', () => { + // `properties` is an open bag: another component's `items` may be option + // rows. Only an object entry carrying a `children` array is a panel. + const doc: any = { + regions: [{ + name: 'main', + components: [{ + id: 'picker', + type: 'element:select', + properties: { items: [{ value: 'a', label: 'A' }, 'bare', null] }, + }], + }], + }; + expect(trace(doc).map((r) => r.id)).toEqual(['picker']); + const { regions } = walkAddressedPageComponents(doc, (c) => c); + // Nothing descended ⇒ `items` is not rebuilt; the node comes back as-is. + expect((regions as any)[0].components[0]).toBe(doc.regions[0].components[0]); + }); + + it('arbitrates a repeated id across ROOTS: a slot entry wins outright over a nested namesake seen earlier in a region', () => { + const doc: any = { + regions: [{ + name: 'main', + components: [{ + id: 'wrap', + type: 'page:card', + properties: { children: [{ id: 'shared', type: 'object-metric', properties: {} }] }, + }], + }], + slots: { details: { id: 'shared', type: 'record:details', properties: {} } }, + }; + expect(trace(doc)).toEqual([ + { id: 'wrap', nested: false, depth: 0, addressed: true }, + { id: 'shared', nested: true, depth: 1, addressed: false }, + { id: 'shared', nested: false, depth: 0, addressed: true }, + ]); + }); + + it('returns each root key exactly when the input carried it, and passes an off-spec slot value through', () => { + const regionsOnly = walkAddressedPageComponents({ regions: [] }, (c) => c); + expect(Object.keys(regionsOnly)).toEqual(['regions']); + + const slotsOnly = walkAddressedPageComponents( + { slots: { header: { id: 'h', type: 'page:header', properties: {} }, alerts: 'not-a-component' } } as any, + (c) => c, + ); + expect(Object.keys(slotsOnly)).toEqual(['slots']); + expect((slotsOnly.slots as any).alerts).toBe('not-a-component'); + expect((slotsOnly.slots as any).header.id).toBe('h'); }); it('stops the descent at the cap: a 40-chain is visited down to depth 32 and no further', () => { @@ -1959,7 +2186,7 @@ describe('walkAddressedPageComponents (#13218)', () => { expect(trace(doc).filter((r) => r.id === 'twice').map((r) => r.addressed)).toEqual([true, false]); }); - it("replaces each node with the visitor's return and re-attaches rebuilt children — never mutating the input", () => { + it("replaces each node with the visitor's return and re-attaches rebuilt children and panels — never mutating the input", () => { const doc: any = { regions: [{ name: 'main', @@ -1975,11 +2202,16 @@ describe('walkAddressedPageComponents (#13218)', () => { 'bare-component-id-string', null, ], + items: [ + { label: 'Panel', icon: 'list', children: [{ id: 'tab_kid', type: 'object-metric', properties: { title: 'Tab kid' } }] }, + { label: 'Empty' }, + ], }, }], }], + slots: { header: { id: 'hdr', type: 'page:header', properties: { title: 'Header' } } }, }; - const regions = walkAddressedPageComponents(doc, (component, { id }) => ({ + const { regions, slots } = walkAddressedPageComponents(doc, (component, { id }) => ({ ...component, properties: { ...component.properties, title: `visited:${id}` }, })) as any; @@ -1988,9 +2220,165 @@ describe('walkAddressedPageComponents (#13218)', () => { expect(rebuilt.properties.title).toBe('visited:card'); expect(rebuilt.properties.children[0].properties.title).toBe('visited:kid'); expect(rebuilt.properties.children.slice(1)).toEqual(['bare-component-id-string', null]); + // A panel keeps every key of its own (`label`, `icon`) and gets its + // `children` rebuilt; a panel without `children` passes through as-is. + expect(rebuilt.properties.items[0]).toEqual({ + label: 'Panel', icon: 'list', + children: [{ id: 'tab_kid', type: 'object-metric', properties: { title: 'visited:tab_kid' } }], + }); + expect(rebuilt.properties.items[1]).toBe(doc.regions[0].components[0].properties.items[1]); + expect(slots.header.properties.title).toBe('visited:hdr'); // The source document is untouched — both walk consumers rely on it. expect(doc.regions[0].components[0].properties.title).toBe('Card'); expect(doc.regions[0].components[0].properties.children[0].properties.title).toBe('Kid'); + expect(doc.regions[0].components[0].properties.items[0].children[0].properties.title).toBe('Tab kid'); + expect(doc.slots.header.properties.title).toBe('Header'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// #16772 — a `kind: 'slotted'` page goes from unaddressable to addressable +// ──────────────────────────────────────────────────────────────────────────── + +describe('translatePage — slotted page roots and tab panels (#16772)', () => { + /** + * The measured shape, rebuilt as a minimal fixture rather than restated + * from the card: a `kind: 'slotted'` record page authoring `regions: []`, + * its header / path / details under `slots.*`, and a `page:tabs` under + * `slots.tabs` whose panels hold the related lists. Every authored copy + * site that the walk can address carries an id. + */ + const contractPage = () => ({ + name: 'contract_detail', + label: 'Contract', + kind: 'slotted', + object: 'contract', + regions: [] as any[], + slots: { + // THE page's header — root level, so the page-name route reaches it. + header: { type: 'page:header', id: 'hdr', properties: { title: 'Contract', subtitle: 'Lifecycle' } }, + highlights: { type: 'record:path', id: 'stage_path', properties: { label: 'Stage', field: 'stage' } }, + details: [{ type: 'record:details', id: 'main_details', properties: { label: 'Details' } }], + tabs: { + type: 'page:tabs', + id: 'detail_tabs', + properties: { + label: 'Sections', + items: [ + { label: 'Parties', value: 'parties', children: [{ type: 'record:related_list', id: 'rl_parties', properties: { title: 'Parties' } }] }, + { label: 'Clauses', value: 'clauses', children: [{ type: 'record:related_list', id: 'rl_clauses', properties: { title: 'Clauses' } }] }, + { + label: 'Documents', value: 'documents', + children: [{ + type: 'page:card', id: 'doc_card', + properties: { + title: 'Documents', + // Two levels in — a card inside a panel — still reached. + children: [{ type: 'record:related_list', id: 'rl_documents', properties: { title: 'Attached documents' } }], + // CONTROL: `body` is not an authorable composition slot and + // is still not descended. + body: [{ type: 'record:related_list', id: 'rl_body_control', properties: { title: 'Body control' } }], + }, + }], + }, + ], + }, + }, + }, + }); + + /** Every id the fixture authors, so the bundle can offer copy for each. */ + const bundle: TranslationBundle = { + 'zh-CN': { + pages: { + contract_detail: { + label: '合同', + title: '合同详情', + subtitle: '生命周期', + components: { + hdr: { title: 'ID-ROUTE-MUST-NOT-WIN' }, + stage_path: { label: '阶段' }, + main_details: { label: '详细信息' }, + detail_tabs: { label: '分区' }, + rl_parties: { title: '当事方' }, + rl_clauses: { title: '条款' }, + doc_card: { title: '文档' }, + rl_documents: { title: '附件' }, + rl_body_control: { title: 'MUST-NOT-APPLY' }, + }, + }, + }, + }, + }; + + const count = (doc: any): number => { + let n = 0; + walkAddressedPageComponents(doc, (c) => (n++, c)); + return n; + }; + + it('addresses a slotted page — the walk count is non-zero and every authored id is visited once', () => { + // Measured here, on this fixture: 9 components authored (1 header, 1 + // path, 1 details, 1 tabs, 3 related lists in panels, 1 card, 1 nested + // related list); the `body` control is not a visit. The card's own + // number came from one app and one console build and is not restated. + expect(count(contractPage())).toBe(9); + const ids: string[] = []; + walkAddressedPageComponents(contractPage(), (c, { id, addressed }) => { + if (addressed) ids.push(id as string); + return c; + }); + expect(ids).toEqual([ + 'hdr', 'stage_path', 'main_details', 'detail_tabs', + 'rl_parties', 'rl_clauses', 'doc_card', 'rl_documents', + ]); + }); + + it('resolves each authored key — slots roots, tab panels, and a card inside a panel', () => { + const out = translatePage(contractPage(), bundle, { locale: 'zh-CN' }); + const tabs = (out.slots as any).tabs; + expect(out.label).toBe('合同'); + expect((out.slots as any).highlights.properties.label).toBe('阶段'); + expect((out.slots as any).details[0].properties.label).toBe('详细信息'); + expect(tabs.properties.label).toBe('分区'); + expect(tabs.properties.items[0].children[0].properties.title).toBe('当事方'); + expect(tabs.properties.items[1].children[0].properties.title).toBe('条款'); + expect(tabs.properties.items[2].children[0].properties.title).toBe('文档'); + expect(tabs.properties.items[2].children[0].properties.children[0].properties.title).toBe('附件'); + // `regions: []` is carried through unchanged, and no key is invented. + expect(out.regions).toEqual([]); + expect(out.kind).toBe('slotted'); + }); + + it('routes the `slots.header` page:header by PAGE NAME — the id route is not read for a root-level header', () => { + const out = translatePage(contractPage(), bundle, { locale: 'zh-CN' }); + expect((out.slots as any).header.properties).toEqual({ title: '合同详情', subtitle: '生命周期' }); + }); + + it('CONTROL: a component under `body` inside a panel stays untranslated, and a slotted page without a bundle entry is untouched', () => { + const out = translatePage(contractPage(), bundle, { locale: 'zh-CN' }); + const docCard = (out.slots as any).tabs.properties.items[2].children[0]; + expect(docCard.properties.body[0].properties.title).toBe('Body control'); + + const other = translatePage( + { ...contractPage(), name: 'other_page' }, + bundle, + { locale: 'zh-CN' }, + ); + expect(other.slots).toEqual(contractPage().slots); + }); + + it('BOUNDARY: a tab panel `label` (`items[].label`) has no bundle key — its route is the inline locale map', () => { + // Recorded, not endorsed. `PageTabsProps.items[].label` is an + // `I18nLabelSchema`, so a tab strip is localisable at its authoring site; + // a bundle key for the panel (which carries no id and whose `value` is + // optional) is a naming decision this change does not make. If a key is + // added, this pin is the one to flip. + const page = contractPage(); + (page.slots.tabs.properties.items[0] as any).label = { en: 'Parties', 'zh-CN': '当事方' }; + const out = translatePage(page, bundle, { locale: 'zh-CN' }); + expect((out.slots as any).tabs.properties.items[0].label).toEqual({ en: 'Parties', 'zh-CN': '当事方' }); + expect((out.slots as any).tabs.properties.items[1].label).toBe('Clauses'); }); }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 8162eca7d4..a94f3ce15d 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1145,15 +1145,122 @@ export interface WidgetLike { [key: string]: any; } +/** + * Minimal global-filter shape consumed by `translateDashboard` + * (`GlobalFilterSchema`, #16772). + */ +export interface GlobalFilterLike { + /** + * `GlobalFilterSchema.name` — the stable filter key, declared as defaulting + * to `field`. The bundle addresses the filter by whichever of the two the + * document carries first: see {@link globalFilterKey}. + */ + name?: string; + field?: string; + /** `I18nLabelSchema` — a plain string OR an inline locale map. */ + label?: unknown; + /** Static options — `value` is `string | number | boolean`, `label` an `I18nLabelSchema`. */ + options?: Array<{ value?: unknown; label?: unknown; [key: string]: any }>; + [key: string]: any; +} + /** Minimal dashboard metadata shape consumed by `translateDashboard`. */ export interface DashboardLike { name: string; label?: string; description?: string; widgets?: WidgetLike[]; + globalFilters?: GlobalFilterLike[]; [key: string]: any; } +/** + * The key `dashboards..globalFilters.` addresses a filter by — + * its `name`, else its `field`. Not a lenient fallback: `GlobalFilterSchema` + * declares `name` as *"Stable filter name (variable key); defaults to + * field"*, so a filter that authors no `name` IS keyed by its `field` + * everywhere the platform reads it (widget `filterBindings`, the published + * `page.` variable), and the bundle follows the same declaration. + * `undefined` for a filter carrying neither, which is off-spec (`field` is + * required) and passes through untranslated. + */ +export function globalFilterKey(filter: Pick): string | undefined { + if (typeof filter.name === 'string' && filter.name.length > 0) return filter.name; + if (typeof filter.field === 'string' && filter.field.length > 0) return filter.field; + return undefined; +} + +function lookupGlobalFilterLabel( + bundle: TranslationBundle | undefined, + dashboardName: string, + filterKey: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = + pickData(bundle, code)?.dashboards?.[dashboardName]?.globalFilters?.[filterKey]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +function lookupGlobalFilterOption( + bundle: TranslationBundle | undefined, + dashboardName: string, + filterKey: string, + optionValue: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = + pickData(bundle, code)?.dashboards?.[dashboardName]?.globalFilters?.[filterKey]?.options?.[optionValue]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Overlay `dashboards..globalFilters..{label,options.}` + * onto one authored filter (#16772). Returns the input object itself when + * nothing resolved, so `translateDashboard` can tell "untouched" from + * "rebuilt" by identity and leave `globalFilters` off the copy when no filter + * moved. + */ +function translateGlobalFilter( + filter: GlobalFilterLike, + bundle: TranslationBundle, + dashboardName: string, + opts?: ResolveOptions, +): GlobalFilterLike { + const key = globalFilterKey(filter); + if (key === undefined) return filter; + + let next = filter; + const label = lookupGlobalFilterLabel(bundle, dashboardName, key, opts); + if (label !== undefined) next = { ...next, label }; + + if (Array.isArray(filter.options)) { + let changed = false; + const options = filter.options.map((option) => { + if (!option || typeof option !== 'object') return option; + const { value } = option; + // The record key is the option value spelled as a string — the schema + // declares `value` as `string | number | boolean`, so `String(value)` + // is the one spelling every value has; `null`/`undefined`/objects have + // no such spelling and are left alone. + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') return option; + const translated = lookupGlobalFilterOption(bundle, dashboardName, key, String(value), opts); + if (translated === undefined) return option; + changed = true; + return { ...option, label: translated }; + }); + if (changed) next = { ...next, options }; + } + return next; +} + function lookupDashboardAttr( bundle: TranslationBundle | undefined, name: string, @@ -1196,6 +1303,16 @@ function lookupWidgetAttr( * key never reaches `options.description`, and `subCaption` never reaches * `widget.description`; the other `options` keys are carried through * untouched. + * + * Global filters are translated too (#16772), against + * `dashboards..globalFilters..label` and + * `.options.` — the filter bar draws directly above the widget titles, + * and before this it was the one strip of the dashboard no bundle could + * reach. The key is the filter's `name`, else its `field` + * ({@link globalFilterKey}); an option is matched by its `value` spelled as + * a string. Only filters the bundle actually addresses are rebuilt, and + * `globalFilters` is left off the copy entirely when none moved, so a + * dashboard without filters gains no invented key. */ export function translateDashboard( doc: T, @@ -1209,6 +1326,18 @@ export function translateDashboard( const label = lookupDashboardAttr(bundle, name, 'label', opts) ?? doc.label; const description = lookupDashboardAttr(bundle, name, 'description', opts) ?? doc.description; + let globalFilters: GlobalFilterLike[] | undefined; + if (Array.isArray(doc.globalFilters)) { + let changed = false; + const rebuilt = doc.globalFilters.map((filter) => { + if (!filter || typeof filter !== 'object') return filter; + const next = translateGlobalFilter(filter, bundle, name, opts); + if (next !== filter) changed = true; + return next; + }); + if (changed) globalFilters = rebuilt; + } + const widgets = Array.isArray(doc.widgets) ? doc.widgets.map((w) => { if (!w || typeof w !== 'object' || typeof w.id !== 'string') return w; @@ -1228,6 +1357,7 @@ export function translateDashboard( ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}), ...(widgets !== undefined ? { widgets } : {}), + ...(globalFilters !== undefined ? { globalFilters } : {}), }; } @@ -1390,6 +1520,14 @@ export interface PageLike { label?: string; description?: string; regions?: PageRegionLike[]; + /** + * `PageSchema.slots` — where a `kind: 'slotted'` record page authors its + * components (#16772). Each slot holds one component or an array of them + * (`PageSchema` declares the union per slot); the walk tolerates either and + * passes any other value through untouched. Walked as a ROOT alongside + * `regions[].components[]` — see {@link walkAddressedPageComponents}. + */ + slots?: Record; /** Bound object for a record page — the `_tabs` fallback binding (#5377). */ object?: string; /** @@ -1472,8 +1610,9 @@ function lookupPageComponentCopy( } /** - * How many levels of `properties.children` nesting - * {@link walkAddressedPageComponents} descends below region level (#12961). + * How many levels of composition nesting — `properties.children` (#12961) and + * `properties.items[].children` (#16772), each panel costing one level — + * {@link walkAddressedPageComponents} descends below root level. * Authored page trees run three or four deep in practice, so the cap is not a * limit any real document meets — it exists because `children` is authored * data, and a walk that never throws must still be finite on a pathological @@ -1511,15 +1650,20 @@ export interface AddressedPageComponentContext { */ id: string | undefined; /** - * `true` below region level — the component was reached through a - * container's declared `properties.children`. + * `true` below root level — the component was reached through a container's + * declared `properties.children`, or through a `properties.items[].children` + * panel of a `page:tabs` / `page:accordion` (#16772). */ nested: boolean; - /** Levels below region level; region-level components sit at `0`. */ + /** + * Levels below root level; root-level components — an entry of + * `regions[].components[]` or of `slots.` — sit at `0`. An `items[]` + * panel costs one level exactly as a `children` slot does. + */ depth: number; /** * `true` when this component OWNS its id's `pages..components.` - * entry under the ruled collision arbitration (#12961): a region-level + * entry under the ruled collision arbitration (#12961): a root-level * component carrying the id wins outright — even over a nested match seen * earlier in document order — and among nested components the depth-first * document-order FIRST sighting takes it. At most one visited component is @@ -1528,6 +1672,15 @@ export interface AddressedPageComponentContext { addressed: boolean; } +/** + * The two root collections {@link walkAddressedPageComponents} walks and + * rebuilds — `regions` (every page kind) and `slots` (`kind: 'slotted'`, + * #16772). Each key is present on the result exactly when it was present on + * the input, so a consumer can spread the pair back onto the document without + * inventing a `slots` key on a page that never authored one. + */ +export type AddressedPageRoots = Pick; + /** * Depth-first, pre-order walk of the components `pages..components.` * addresses on a page — THE one traversal behind both {@link translatePage} @@ -1541,11 +1694,21 @@ export interface AddressedPageComponentContext { * ignores, or omitting one it reads (#13109 was the second half going live). * Five invariants live here and ONLY here: * - * - roots: `regions[].components[]` only — `slots` is not walked, on any - * page; - * - descent: a container's declared `properties.children` only, recursively - * — the one composition key (#5775); `body` / `footer` / - * `items[].children` are deliberately not descended; + * - roots: `regions[].components[]` AND `slots.` — a `kind: 'slotted'` + * record page authors its components under `slots`, where each slot is one + * component or an array of them (`PageSchema.slots`), and before #16772 + * such a page had exactly two addressable keys (`label`, `description`) + * however many components it authored. Regions first, then the slots in + * authored key order; both are ROOT level (depth `0`, `nested: false`); + * - descent: a container's declared `properties.children` (#5775, the one + * composition key) AND a `page:tabs` / `page:accordion` panel's + * `properties.items[].children` (#16772 — the panel object itself is not + * a component and is not visited; its `children` sit one level below the + * tabs node, exactly as a `children` entry would). Both are matched by + * SHAPE, not by component type, because `properties` is an open bag and + * custom component types are legal. `body` / `footer` are deliberately + * still not descended — a renderer-side back-compat fallback for stored + * documents, not an authorable spelling; * - the descent is depth-capped ({@link MAX_NESTED_COMPONENT_DEPTH}, * module-private — the walk is the contract, the number is its safety * property); @@ -1556,32 +1719,50 @@ export interface AddressedPageComponentContext { * * The visitor is called for EVERY component the walk reaches (addressed or * not), parent before children, siblings in document order. Its return value - * REPLACES the node in the rebuilt region tree the walk returns; after the - * visitor runs, the walk re-attaches the node's rebuilt `children` array in - * place of the existing key, so the visitor never needs to recurse itself. - * Enumeration-only consumers return the component unchanged and ignore the - * walk's return value. The input document is never mutated. Entries of - * `children` that are not component objects (bare id strings, `null` — the - * slot is `z.array(z.unknown())`) pass through unvisited, and a region whose - * shape is off-spec is returned as-is. + * REPLACES the node in the rebuilt root trees the walk returns; after the + * visitor runs, the walk re-attaches the node's rebuilt `children` array (and + * rebuilt `items[].children` arrays) in place of the existing keys, so the + * visitor never needs to recurse itself. Enumeration-only consumers return + * the component unchanged and ignore the walk's return value. The input + * document is never mutated. Entries of `children` that are not component + * objects (bare id strings, `null` — the slot is `z.array(z.unknown())`) pass + * through unvisited; a region or slot whose shape is off-spec is returned + * as-is. + * + * Returns the rebuilt {@link AddressedPageRoots} — `regions` and `slots`, each + * present exactly when present on the input. */ export function walkAddressedPageComponents( - doc: Pick, + doc: AddressedPageRoots, visit: (component: PageComponentLike, context: AddressedPageComponentContext) => PageComponentLike, -): PageLike['regions'] { - // Collision arbitration, pass 1 (#12961): every id carried by a REGION-LEVEL - // component. The ruling makes region level the outright winner when an id - // repeats across levels, so the whole set has to be known before the descent - // visits its first nested component — a region-level namesake in a LATER - // region still beats a nested match seen earlier. - const regionLevelIds = new Set(); +): AddressedPageRoots { + const slots = doc.slots && typeof doc.slots === 'object' && !Array.isArray(doc.slots) + ? doc.slots + : undefined; + + // Collision arbitration, pass 1 (#12961): every id carried by a ROOT-LEVEL + // component — a region's entry or a slot's. The ruling makes root level the + // outright winner when an id repeats across levels, so the whole set has to + // be known before the descent visits its first nested component — a + // root-level namesake in a LATER region or slot still beats a nested match + // seen earlier. + const rootLevelIds = new Set(); + const claimRootIds = (components: unknown[]): void => { + for (const component of components) { + const id = pageComponentId(component as PageComponentLike); + if (id !== undefined) rootLevelIds.add(id); + } + }; if (Array.isArray(doc.regions)) { for (const region of doc.regions) { if (!region || typeof region !== 'object' || !Array.isArray(region.components)) continue; - for (const component of region.components) { - const id = pageComponentId(component); - if (id !== undefined) regionLevelIds.add(id); - } + claimRootIds(region.components); + } + } + if (slots) { + for (const slot of Object.values(slots)) { + if (Array.isArray(slot)) claimRootIds(slot); + else if (slot && typeof slot === 'object') claimRootIds([slot]); } } @@ -1598,19 +1779,49 @@ export function walkAddressedPageComponents( const ancestors = new Set(); /** - * The component's rebuilt `properties.children`, or `undefined` when there - * is nothing to descend into — so a component without the slot is returned - * untouched rather than gaining an invented `properties` bag. + * The component's rebuilt composition slots — `properties.children` and + * `properties.items` (each panel's `children` rebuilt) — or `undefined` for + * a slot there is nothing to descend into, so a component without either is + * returned untouched rather than gaining an invented `properties` bag. */ - const walkChildren = (component: PageComponentLike, depth: number): unknown[] | undefined => { + const walkComposition = ( + component: PageComponentLike, + depth: number, + ): { children?: unknown[]; items?: unknown[] } | undefined => { if (depth >= MAX_NESTED_COMPONENT_DEPTH) return undefined; const props = component.properties; if (!props || typeof props !== 'object' || Array.isArray(props)) return undefined; - const children = (props as Record).children; - if (!Array.isArray(children)) return undefined; + const { children, items } = props as Record; + const hasChildren = Array.isArray(children); + // A panel is descended when it is an object carrying a `children` array; + // anything else in `items` (an option row of some other component, a bare + // string) passes through untouched, and `items` is only rebuilt when at + // least one panel was descended. + const panels = Array.isArray(items) + ? items.map((item) => + item && typeof item === 'object' && !Array.isArray(item) + && Array.isArray((item as Record).children)) + : undefined; + const hasPanels = panels !== undefined && panels.some(Boolean); + if (!hasChildren && !hasPanels) return undefined; ancestors.add(component); try { - return children.map((child) => visitComponent(child as PageComponentLike, depth + 1)); + const rebuilt: { children?: unknown[]; items?: unknown[] } = {}; + if (hasChildren) { + rebuilt.children = children.map((child) => visitComponent(child as PageComponentLike, depth + 1)); + } + if (hasPanels) { + rebuilt.items = (items as unknown[]).map((item, index) => { + if (!panels![index]) return item; + const panel = item as Record; + return { + ...panel, + children: (panel.children as unknown[]).map((child) => + visitComponent(child as PageComponentLike, depth + 1)), + }; + }); + } + return rebuilt; } finally { ancestors.delete(component); } @@ -1627,28 +1838,45 @@ export function walkAddressedPageComponents( const nested = depth > 0; const id = pageComponentId(component); const addressed = id !== undefined - && (!nested || (!regionLevelIds.has(id) && !claimedNestedIds.has(id))); + && (!nested || (!rootLevelIds.has(id) && !claimedNestedIds.has(id))); if (addressed && nested) claimedNestedIds.add(id as string); // Pre-order: the visitor sees the parent before its children, so a // consumer that emits in visit order emits in document order. The rebuilt - // children land on the RETURNED node afterwards — `children` is the slot - // the walk owns; everything else on the node is the visitor's. + // composition slots land on the RETURNED node afterwards — `children` and + // `items[].children` are the slots the walk owns; everything else on the + // node is the visitor's. let next = visit(component, { id, nested, depth, addressed }); - const children = walkChildren(component, depth); - if (children !== undefined) { - next = { ...next, properties: { ...next.properties, children } }; + const rebuilt = walkComposition(component, depth); + if (rebuilt !== undefined) { + next = { ...next, properties: { ...next.properties, ...rebuilt } }; } return next; }; - return Array.isArray(doc.regions) + const walkRoots = (components: unknown[]): PageComponentLike[] => + components.map((c) => visitComponent(c as PageComponentLike, 0)); + + const regions = Array.isArray(doc.regions) ? doc.regions.map((region) => { if (!region || typeof region !== 'object' || !Array.isArray(region.components)) return region; - return { ...region, components: region.components.map((c) => visitComponent(c, 0)) }; + return { ...region, components: walkRoots(region.components) }; }) : doc.regions; + + const rebuiltSlots = slots + ? Object.fromEntries(Object.entries(slots).map(([slotName, slot]) => { + if (Array.isArray(slot)) return [slotName, walkRoots(slot)]; + if (slot && typeof slot === 'object') return [slotName, visitComponent(slot as PageComponentLike, 0)]; + return [slotName, slot]; + })) + : doc.slots; + + return { + ...('regions' in doc ? { regions } : {}), + ...('slots' in doc ? { slots: rebuiltSlots } : {}), + }; } /** @@ -1663,8 +1891,10 @@ export function walkAddressedPageComponents( * `pages..label` so translators need not repeat a string that is normally * identical to the page's nav label. * - * **One component, one address.** A REGION-LEVEL `page:header` is addressed by - * page name and by nothing else: the id route + * **One component, one address.** A ROOT-LEVEL `page:header` — an entry of a + * region's `components[]`, or of a `slots.` on a `kind: 'slotted'` page + * (its `slots.header` IS the page's header) — is addressed by page name and + * by nothing else: the id route * (`pages..components..*`) is NOT read for it, even when it carries * an id (ruled 2026-09-06, decision batch #58 — the page-name route is * canonical). It used to be read there and to WIN, which put the header's @@ -1694,19 +1924,29 @@ export function walkAddressedPageComponents( * ruling widens the resolver to the published face rather than narrowing a * released face. * - * `children` is the ONLY slot descended — it is the one composition key - * (#5775). `body` is a renderer-side back-compat fallback for stored - * documents, not an authorable spelling, so descending it would resurrect a - * second spelling; `properties.items[].children` (`page:tabs`, - * `page:accordion`) sits one level deeper than the slot the ruling names and - * is left for its own contract call. + * Two composition slots are descended: `children` — the one composition key + * (#5775) — and, since #16772, a `page:tabs` / `page:accordion` panel's + * `items[].children`, which sits one level deeper than the slot the #12961 + * ruling named and was left for its own contract call; that call is #16772, + * measured on a slotted contract page whose seven tab panels held every + * related list and the resolver reached none of them. `body` / `footer` stay + * undescended: a renderer-side back-compat fallback for stored documents, not + * an authorable spelling, so descending them would resurrect a second + * composition spelling. + * + * The roots widened in the same change: a `kind: 'slotted'` record page + * authors its components under `slots.` and `regions: []`, so before + * #16772 the walk visited NOTHING on such a page and `pages.` carried + * exactly two addressable keys however many components the page authored. + * `slots` entries are ROOT level — the same standing as a region's entry, in + * the collision arbitration and for the page-name header route below. * * Nested components are reached by the **id route only**. The page-name header * route addresses THE page's header, and a `page:header` nested inside a - * container is not it. + * container (or inside a tab panel) is not it. * * When one id appears more than once, the ruling fixes the winner: a - * region-level component carrying it WINS outright, and among nested + * root-level component carrying it WINS outright, and among nested * components the document-order (depth-first) FIRST match takes it — one * bundle entry, one component. The descent is depth-capped * ({@link MAX_NESTED_COMPONENT_DEPTH}) and cycle-safe, because `children` is @@ -1744,7 +1984,7 @@ export function translatePage( // per-component overlay; the walk re-attaches each node's translated // `children` after the visitor returns, so the overlay never contends with // the descent for a key (`children` is not a copy key). - const regions = walkAddressedPageComponents(doc, (component, { nested, id, addressed }) => { + const { regions, slots } = walkAddressedPageComponents(doc, (component, { nested, id, addressed }) => { // Per-component copy (#6080) — addressed by the component's own id, and // applied before the page-name route below. `addressed` carries the ruled // collision arbitration (#12961), so a looked-up entry is this component's @@ -1753,9 +1993,10 @@ export function translatePage( // claim-on-first-sighting selects the same component a // claim-on-resolved-lookup would. // - // The one component this route does NOT serve is a REGION-LEVEL - // `page:header`: its copy is addressed by page name below, and reading - // `components.` for it too would give one string two addresses. The + // The one component this route does NOT serve is a ROOT-LEVEL + // `page:header` (a region's entry or a `slots.` entry): its copy is + // addressed by page name below, and reading `components.` for it too + // would give one string two addresses. The // condition is written to MIRROR the extractor's emission exception in // `collectExpectedEntries` (`packages/cli`) — same shape, opposite verb — // so the pair the shared walk exists to prevent cannot reopen from this @@ -1763,7 +2004,7 @@ export function translatePage( // which is the only route that reaches it. // // The walk's `addressed` arbitration is deliberately NOT touched: a - // region-level `page:header`'s id still CLAIMS its bundle entry and still + // root-level `page:header`'s id still CLAIMS its bundle entry and still // blocks a nested namesake. Which component owns an id is a property of // the document, decided identically for every consumer of the walk; only // whether this consumer READS the entry changes here, and the extractor @@ -1794,7 +2035,7 @@ export function translatePage( } // The page-name header route addresses THE page's header, so it stops at - // region level — nested components are reached by the id route only. + // root level — nested components are reached by the id route only. if (nested) return next; if (next.type !== PAGE_HEADER_COMPONENT) return next; if (headerTitle === undefined && headerSubtitle === undefined) return next; @@ -1819,6 +2060,7 @@ export function translatePage( ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}), ...(regions !== undefined ? { regions } : {}), + ...(slots !== undefined ? { slots } : {}), ...(interfaceConfig !== undefined ? { interfaceConfig } : {}), }; } diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 15783ab646..95213cbdc7 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -785,6 +785,7 @@ describe('translation unknown-key strictness (#4001)', () => { ['an app translation', { apps: { crm: { label: 'CRM', nagivation: {} } } }, 'navigation'], ['a page translation', { pages: { home: { subtitel: 'Welcome' } } }, 'subtitle'], ['a dashboard widget', { dashboards: { sales: { widgets: { rev: { titel: 'Revenue' } } } } }, 'title'], + ['a dashboard global filter', { dashboards: { sales: { globalFilters: { region: { lable: 'Region' } } } } }, 'label'], ['a settings key', { settings: { mail: { keys: { host: { lable: 'Host' } } } } }, 'label'], ['a metadata form field', { metadataForms: { object: { fields: { name: { helpTxt: 'x' } } } } }, 'helpText'], ])('rejects a typo in %s and names the key it meant', (_what, body, expected) => { @@ -818,6 +819,67 @@ describe('translation unknown-key strictness (#4001)', () => { .toContain('`help` → `helpText`'); }); + // ────────────────────────────────────────────────────────────────────────── + // #16772 — `dashboards..globalFilters.`, the filter bar's copy + // ────────────────────────────────────────────────────────────────────────── + describe('dashboard global-filter copy (#16772)', () => { + const parse = (globalFilters: unknown) => + TranslationDataSchema.safeParse({ dashboards: { sales: { label: 'Sales', globalFilters } } }); + + it('accepts the key face — `label` and an option value→label map — keyed by filter name', () => { + const result = parse({ + region: { label: 'Region', options: { emea: 'EMEA', apac: 'APAC' } }, + // A filter keyed by its `field` (no authored `name`), label only. + category: { label: 'Category' }, + // Options only — a translator may translate the values and keep the + // authored label. + status: { options: { '1': 'Active', 'true': 'Yes' } }, + }); + expect(result.success).toBe(true); + expect(result.success && result.data.dashboards?.sales.globalFilters).toEqual({ + region: { label: 'Region', options: { emea: 'EMEA', apac: 'APAC' } }, + category: { label: 'Category' }, + status: { options: { '1': 'Active', 'true': 'Yes' } }, + }); + }); + + it('CONTROL: the group was unaddressable before — the same face on a 17.3-shaped bundle is what the strict object refused', () => { + // Not a phantom check: the assertion below is the accept side of the + // widening, and the reject side is the `unrecognized_keys` the group's + // members still produce for any spelling outside the face. + const result = parse({ region: { label: 'Region', title: 'Region' } }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain('this dashboard global-filter translation'); + expect(issue?.message).toContain('`title` → `label`'); + }); + + it.each([ + ['choices', 'options'], + ['values', 'options'], + ['name', 'label'], + ])('points the dashboard-document spelling `%s` at `%s`', (alias, expected) => { + const result = parse({ region: { [alias]: {} } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain(`\`${alias}\` → \`${expected}\``); + }); + + it('points `filters` / `globalFilter` on the dashboard entry at `globalFilters`, as the dashboard schema does', () => { + for (const alias of ['filters', 'globalFilter']) { + const result = TranslationDataSchema.safeParse({ dashboards: { sales: { [alias]: {} } } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain(`\`${alias}\` → \`globalFilters\``); + } + }); + + it('rejects a non-string option label — the map is value → translated label, nothing deeper', () => { + const result = parse({ region: { options: { emea: { label: 'EMEA' } } } }); + expect(result.success).toBe(false); + }); + }); + // ────────────────────────────────────────────────────────────────────────── // #6080 — `pages..components.`, the page half of `dashboards.widgets` // ────────────────────────────────────────────────────────────────────────── @@ -1195,7 +1257,7 @@ describe('translation unknown-key strictness (#4001)', () => { apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, messages: { 'common.save': 'Save' }, globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, - dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', subCaption: 'vs last quarter' } } } }, + dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', subCaption: 'vs last quarter' } }, globalFilters: { region: { label: 'Region', options: { emea: 'EMEA' } } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } }, settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index b116d3f5fa..b6663c982e 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -690,11 +690,17 @@ const translationDataShape = () => ({ * dashboards..widgets..title * dashboards..widgets..description * dashboards..widgets..subCaption + * dashboards..globalFilters..label + * dashboards..globalFilters..options. */ dashboards: z.record(z.string(), strictObject({ surface: 'this dashboard translation', history: TRANSLATION_HISTORY, - aliases: { name: 'label', title: 'label', components: 'widgets', charts: 'widgets', cards: 'widgets' }, + // `filters` / `globalFilter` mirror `DashboardSchema`'s own alias table for + // the authored key, so an author who spells the group the way the + // dashboard document accepts it is pointed at the one spelling the bundle + // takes. + aliases: { name: 'label', title: 'label', components: 'widgets', charts: 'widgets', cards: 'widgets', filters: 'globalFilters', globalFilter: 'globalFilters' }, }, { label: z.string().optional().describe('Translated dashboard title'), description: z.string().optional().describe('Translated dashboard description'), @@ -735,6 +741,52 @@ const translationDataShape = () => ({ */ subCaption: z.string().optional().describe("Translated metric sub-caption (overlays the widget's `options.description`, a different authored field from `description`)"), })).optional().describe('Widget translations keyed by widget id'), + /** + * Global-filter copy, keyed by the filter's stable `name` + * (`GlobalFilterSchema.name`, which the dashboard schema declares as + * defaulting to `field` — a filter that authors no `name` is addressed by + * its `field`, the same key its value is published under and that + * widgets reference in `filterBindings`). + * + * **The hole this closes (#16772).** The filter bar draws DIRECTLY ABOVE + * the widget titles this group has always translated, and neither a + * filter's label nor its static option labels had any key here — so a + * translated dashboard rendered `Requesting Department: 全部` over six + * Chinese widget titles. Not a drifted key: no key. `label` overlays + * `globalFilters[].label`; `options.` overlays the matching + * `globalFilters[].options[].label`, keyed by the option's `value` + * spelled as a string (`String(value)` — the option value is declared + * `string | number | boolean`, and a record key can only be a string). + * Resolved by `translateDashboard` (i18n-resolver.ts) on the served + * document; objectui's filter bar reads the served `label` / option + * `label` through `pickLocalized`, so no client change is needed. + * + * **Two routes, and which one this is.** A filter bound to an object field + * may also set `object` (#7804), in which case the CONSOLE resolves its + * field label and option labels through `objects..fields.` + * client-side. That route is object-scoped and shared across every + * dashboard drawing that field; this group is dashboard-scoped copy for + * the filter AS THIS DASHBOARD LABELS IT — the authored `label` a + * translator sees beside the widget titles. A filter that omits `object` + * (every filter in the measured app) has only this route. + * + * `optionsFrom` options are NOT here: they are fetched rows, labelled by + * the source object's `labelField` at request time, and a bundle key for + * a value that exists only in data would be the declared-but-unresolvable + * shape this file exists to keep out. + */ + globalFilters: z.record(z.string(), strictObject({ + surface: 'this dashboard global-filter translation', + history: TRANSLATION_HISTORY, + // `title` / `name` are the `GlobalFilterSchema` alias spellings for the + // authored label; `choices` / `values` are its alias spellings for + // `options` — the same words an author reaches for on the dashboard + // document itself. + aliases: { name: 'label', title: 'label', text: 'label', choices: 'options', values: 'options', items: 'options' }, + }, { + label: z.string().optional().describe("Translated filter label (overlays the filter's authored `label` in the dashboard filter bar)"), + options: z.record(z.string(), z.string()).optional().describe("Static option value to translated label map (overlays `options[].label` for the option whose `value`, spelled as a string, matches the key)"), + })).optional().describe('Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`)'), })).optional().describe('Dashboard translations keyed by dashboard name'), /** From d535eaf56dd1847f2e32ec03e009745bfe0387a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:12:51 +0000 Subject: [PATCH 2/7] wip(i18n): translate the newly addressable global-filter keys; fix test typing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- ...07-unknown-key-strictness-ledger.counts.md | 2 +- .../src/system/translations/index.ts | 28 +++++++++++++++++++ .../src/apps/translations/en.ts | 6 ++++ .../src/apps/translations/es-ES.ts | 6 ++++ .../src/apps/translations/ja-JP.ts | 6 ++++ .../src/apps/translations/zh-CN.ts | 6 ++++ .../spec/src/system/i18n-resolver.test.ts | 19 +++++++------ 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index d138ab014c..092f44feb7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,4 +264,4 @@ directory rather than per file. | `kernel/` | 260 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 350 | +| `system/` | 351 | diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 8e9a838829..87519c6f6d 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -323,6 +323,20 @@ export const ShowcaseTranslationBundle = { kpi_paid_rate: { title: 'Paid Rate' }, table_rate_by_status: { title: 'Paid Rate by Status' }, }, + // The filter bar drawn above the widgets — declared surface since + // `dashboards..globalFilters` (#16772), keyed by the filter's + // `name`; static options keyed by their `value`. + globalFilters: { + region: { label: 'Region', options: { amer: 'AMER', emea: 'EMEA', apac: 'APAC' } }, + }, + }, + showcase_ops_dashboard: { + globalFilters: { + task_status: { + label: 'Task Status', + options: { backlog: 'Backlog', todo: 'To Do', in_progress: 'In Progress', in_review: 'In Review', done: 'Done' }, + }, + }, }, }, }, @@ -973,6 +987,20 @@ export const ShowcaseTranslationBundle = { kpi_paid_rate: { title: '已付比例' }, table_rate_by_status: { title: '各状态已付比例' }, }, + // Born under the ratchet with `dashboards..globalFilters` + // (#16772): the filter label and its static option labels are + // declared surface now, so they are translated at birth. + globalFilters: { + region: { label: '区域', options: { amer: '美洲', emea: '欧洲、中东和非洲', apac: '亚太' } }, + }, + }, + showcase_ops_dashboard: { + globalFilters: { + task_status: { + label: '任务状态', + options: { backlog: '待办池', todo: '待处理', in_progress: '进行中', in_review: '审核中', done: '已完成' }, + }, + }, }, }, // Page component copy became declared surface with `pages..components` diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 6c813a1f39..602e54f350 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -229,6 +229,12 @@ export const en: TranslationData = { description: 'Event volume grouped by action (login, logout, config, …)', }, }, + // The date-range bar above the widgets — addressable since + // `dashboards..globalFilters` (#16772), keyed by the filter's + // `field` because it authors no `name`. + globalFilters: { + created_at: { label: 'Date Range' }, + }, }, }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 164b5e3d85..928e4b4acc 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -154,6 +154,12 @@ export const esES: TranslationData = { widget_events_by_user: { title: 'Eventos por Usuario', description: 'Distribución de actividad entre usuarios' }, widget_recent_events: { title: 'Volumen de Eventos por Acción', description: 'Volumen de eventos agrupado por acción (inicio de sesión, cierre de sesión, configuración, …)' }, }, + // The date-range bar above the widgets — addressable since + // `dashboards..globalFilters` (#16772), keyed by the filter's + // `field` because it authors no `name`. + globalFilters: { + created_at: { label: 'Rango de fechas' }, + }, }, }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index c6f50c8c0f..6b5a2502dc 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -154,6 +154,12 @@ export const jaJP: TranslationData = { widget_events_by_user: { title: 'ユーザー別イベント', description: 'ユーザー別アクティビティ分布' }, widget_recent_events: { title: 'アクション別イベント件数', description: 'アクション別にグループ化されたイベント件数(ログイン、ログアウト、構成など)' }, }, + // The date-range bar above the widgets — addressable since + // `dashboards..globalFilters` (#16772), keyed by the filter's + // `field` because it authors no `name`. + globalFilters: { + created_at: { label: '日付範囲' }, + }, }, }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index e30c4a1043..7e206cc17f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -164,6 +164,12 @@ export const zhCN: TranslationData = { widget_events_by_user: { title: '按用户分布的事件', description: '用户活动分布' }, widget_recent_events: { title: '按操作统计的事件量', description: '按操作分组的事件量(登录、登出、配置等)' }, }, + // The date-range bar above the widgets — addressable since + // `dashboards..globalFilters` (#16772), keyed by the filter's + // `field` because it authors no `name`. + globalFilters: { + created_at: { label: '日期范围' }, + }, }, }, diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index d8a96ac5e4..18efe90343 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1175,12 +1175,14 @@ describe('translateDashboard — global filters (#16772)', () => { }); it('translates the filter label and its static option labels, keyed by `name`', () => { - const out = translateDashboard(dashboard(), bundle, { locale: 'zh-CN' }); + // The chain is the deployment's DECLARED one (#14882): `en` is consulted + // because the caller names it, never by default. + const out = translateDashboard(dashboard(), bundle, { locale: 'zh-CN', fallbackChain: ['en'] }); expect(out.globalFilters![0].label).toBe('申请部门'); expect(out.globalFilters![0].options).toEqual([ { value: 'legal', label: '法务' }, { value: 'sales', label: '销售' }, - // Resolved key by key along the locale chain — `ops` comes from `en`. + // Resolved key by key along the declared chain — `ops` comes from `en`. { value: 'ops', label: 'Operations' }, // No entry anywhere on the chain: authored label kept. { value: 'hr', label: 'HR' }, @@ -1211,7 +1213,7 @@ describe('translateDashboard — global filters (#16772)', () => { expect(out.globalFilters![2]).toBe(doc.globalFilters[2]); // The input is never mutated. expect(doc.globalFilters[0].label).toBe('Requesting Department'); - expect(doc.globalFilters[0].options[0].label).toBe('Legal'); + expect(doc.globalFilters[0].options?.[0]?.label).toBe('Legal'); }); it('CONTROL: leaves `globalFilters` off the copy when nothing resolved, and invents none on a dashboard without filters', () => { @@ -2318,11 +2320,12 @@ describe('translatePage — slotted page roots and tab panels (#16772)', () => { }; it('addresses a slotted page — the walk count is non-zero and every authored id is visited once', () => { - // Measured here, on this fixture: 9 components authored (1 header, 1 - // path, 1 details, 1 tabs, 3 related lists in panels, 1 card, 1 nested - // related list); the `body` control is not a visit. The card's own - // number came from one app and one console build and is not restated. - expect(count(contractPage())).toBe(9); + // Measured here, on this fixture: 8 components authored (1 header, 1 + // path, 1 details, 1 tabs, 2 related lists directly in panels, 1 card in + // a panel, 1 related list nested in that card); the `body` control is + // not a visit. The card's own number came from one app and one console + // build and is not restated. + expect(count(contractPage())).toBe(8); const ids: string[] = []; walkAddressedPageComponents(contractPage(), (c, { id, addressed }) => { if (addressed) ids.push(id as string); From f3e92ee1c862ebcaf7e11994bee0106426166fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:25:35 +0000 Subject: [PATCH 3/7] wip(i18n): regenerate spec artifacts; re-measure the platform-page boundary pin Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- content/docs/references/api/protocol.mdx | 2 +- .../docs/references/system/translation.mdx | 6 ++- .../test/platform-page-i18n-parity.test.ts | 54 ++++++++++--------- packages/spec/api-surface/system.json | 3 ++ packages/spec/export-origins/system.json | 3 ++ 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 38f24ad2ad..4a6ea712e8 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1575,7 +1575,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | -| **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 47cc4708c0..d0a74a6331 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -232,7 +232,7 @@ Translation data for objects, apps, and UI messages | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | -| **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | @@ -283,6 +283,7 @@ Translation data for a single object | **description** | `string` | optional | Translated dashboard description | | **actions** | `Record` | optional | Header action label translations keyed by action url/key | | **widgets** | `Record` | optional | Widget translations keyed by widget id | +| **globalFilters** | `Record }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) | ### Nested Shape: `TranslationData.datasets[string]` @@ -382,7 +383,7 @@ One locale of translations — the `translation` metadata type | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | -| **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | @@ -443,6 +444,7 @@ Translation data for a single object | **description** | `string` | optional | Translated dashboard description | | **actions** | `Record` | optional | Header action label translations keyed by action url/key | | **widgets** | `Record` | optional | Widget translations keyed by widget id | +| **globalFilters** | `Record }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) | ### Nested Shape: `TranslationItem.datasets[string]` diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 70820613d8..cb1061ff2d 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -29,7 +29,7 @@ import { import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; import { SetupAppTranslations } from '@objectstack/platform-objects'; import * as PlatformPages from '@objectstack/platform-objects/pages'; -import { PAGE_COMPONENT_COPY_KEYS, translatePage } from '@objectstack/spec/system'; +import { PAGE_COMPONENT_COPY_KEYS, translatePage, walkAddressedPageComponents } from '@objectstack/spec/system'; import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; /** The pages exactly as the plugins register them with the kernel. */ @@ -791,31 +791,35 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); }); - it('records that the extractor now reaches under `slots` — and that every site it reaches there is an inline locale map with no seed', () => { - // A BOUNDARY PIN, not an endorsement — moved, not removed. Until #16772 - // this pinned `offered: ['label']`: the shared walk rooted at - // `regions[].components[]`, these pages author `regions: []`, and the 45 - // inline sites under `slots.*` had no bundle face. #16772 widened the - // walk to the `slots.` roots and to `items[].children`, so the - // notice the old pin promised has fired, and this is the answer to it: - // these pages' copy is authored as inline locale maps (the ruled route for - // page copy, judged complete by the next case), so what the extractor - // offers for them is a set of `inlineLocales` rows — authored-with-no- - // seed, never a string to translate. The bundle surface they gained - // therefore needs NO entries, and their coverage home stays this file. - // What this pin holds: the reach is real (more than the label alone), and - // it exposes no seeded string for a translator to be asked for. + it('records that the walk now reaches under `slots`, that these pages author no component id there, and so the extractor still offers the label alone', () => { + // A BOUNDARY PIN, not an endorsement — re-measured, and the reason moved. + // Until #16772 `offered: ['label']` held because the shared walk rooted at + // `regions[].components[]` and these pages author `regions: []`: the 45 + // inline sites under `slots.*` were UNREACHABLE. #16772 widened the walk + // to the `slots.` roots and to `items[].children`, and the notice the + // old pin promised fired — so this is the answer to it, measured off the + // documents: the walk now VISITS every component under `slots` (`reached` + // below), and not one of them carries an `id`, so nothing is addressable + // by `pages..components.` and the extractor still offers the + // page label alone. These pages gained no bundle surface, need no entries, + // and their coverage home stays this file (the inline-map case below). + // Both halves are held so the next change is told precisely: `reached` + // reds if the roots narrow again; `offered` grows the day one of these + // components takes an id, and that component then needs a bundle entry. for (const page of RECORD_PAGES) { - const entries = collectExpectedEntries({ pages: [page] } as any) - .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name); - const offered = entries.map((e) => e.path.slice(2).join('.')).sort(); - expect(page.regions).toEqual([]); - expect(offered).toContain('label'); - expect(offered.filter((k) => k.startsWith('components.')).length).toBeGreaterThan(0); - const seeded = entries - .filter((e) => e.path[2] === 'components' && e.inline !== undefined) - .map((e) => e.path.slice(2).join('.')); - expect({ page: page.name, seededUnderSlots: seeded }).toEqual({ page: page.name, seededUnderSlots: [] }); + let visited = 0; + let addressed = 0; + walkAddressedPageComponents(page as any, (component, ctx) => { + visited += 1; + if (ctx.addressed) addressed += 1; + return component; + }); + const offered = collectExpectedEntries({ pages: [page] } as any) + .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name) + .map((e) => e.path.slice(2).join('.')) + .sort(); + expect({ page: page.name, regions: page.regions, reached: visited > 0, addressed, offered }) + .toEqual({ page: page.name, regions: [], reached: true, addressed: 0, offered: ['label'] }); } }); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9b57063e2c..7740243b35 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -12,6 +12,7 @@ "ActionResultDialogTranslationSchema (const)", "AddFieldOperation (const)", "AddressedPageComponentContext (interface)", + "AddressedPageRoots (type)", "AdvancedAuthConfig (type)", "AdvancedAuthConfigSchema (const)", "AnalyzerConfig (type)", @@ -266,6 +267,7 @@ "FlowScreenLike (interface)", "GCounter (type)", "GCounterSchema (const)", + "GlobalFilterLike (interface)", "HistogramBucketConfig (type)", "HistogramBucketConfigSchema (const)", "HttpDestinationConfig (type)", @@ -778,6 +780,7 @@ "docAudienceAllows (function)", "emailTemplateForm (const)", "gcsStorageExample (const)", + "globalFilterKey (function)", "hasObservedDeviation (function)", "hasPlatformObjectPrefix (function)", "inProcessServiceMessage (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 746dd2bf1e..97d21f11a6 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -12,6 +12,7 @@ "ActionResultDialogTranslationSchema": "src/system/translation.zod.ts#ActionResultDialogTranslationSchema (const)", "AddFieldOperation": "src/system/migration.zod.ts#AddFieldOperation (const)", "AddressedPageComponentContext": "src/system/i18n-resolver.ts#AddressedPageComponentContext (interface)", + "AddressedPageRoots": "src/system/i18n-resolver.ts#AddressedPageRoots (type)", "AdvancedAuthConfig": "src/system/auth-config.zod.ts#AdvancedAuthConfig (type)", "AdvancedAuthConfigSchema": "src/system/auth-config.zod.ts#AdvancedAuthConfigSchema (const)", "AnalyzerConfig": "src/system/search-engine.zod.ts#AnalyzerConfig (type)", @@ -258,6 +259,7 @@ "FlowScreenLike": "src/system/i18n-resolver.ts#FlowScreenLike (interface)", "GCounter": "src/system/collaboration.zod.ts#GCounter (type)", "GCounterSchema": "src/system/collaboration.zod.ts#GCounterSchema (const)", + "GlobalFilterLike": "src/system/i18n-resolver.ts#GlobalFilterLike (interface)", "HistogramBucketConfig": "src/system/metrics.zod.ts#HistogramBucketConfig (type)", "HistogramBucketConfigSchema": "src/system/metrics.zod.ts#HistogramBucketConfigSchema (const)", "HttpDestinationConfig": "src/system/logging.zod.ts#HttpDestinationConfig (type)", @@ -739,6 +741,7 @@ "docAudienceAllows": "src/system/book.zod.ts#docAudienceAllows (function)", "emailTemplateForm": "src/system/email-template.form.ts#emailTemplateForm (const)", "gcsStorageExample": "src/system/object-storage.zod.ts#gcsStorageExample (const)", + "globalFilterKey": "src/system/i18n-resolver.ts#globalFilterKey (function)", "hasObservedDeviation": "src/system/migration.zod.ts#hasObservedDeviation (function)", "hasPlatformObjectPrefix": "src/system/constants/platform-object-names.ts#hasPlatformObjectPrefix (function)", "inProcessServiceMessage": "src/system/core-services.zod.ts#inProcessServiceMessage (function)", From af0593780f0ec2b9689b53f457ab3eb66eef89dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 13:22:59 +0000 Subject: [PATCH 4/7] docs(i18n): re-measure the extractor-config note now that the walk reaches under slots; declare the platform-objects bundle rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform-objects extract config explained its missing `pages` key by the shared walk rooting at `regions[].components[]` only. The walk now also roots at `slots.`, so the reason moved: it reaches every component on the three shipped record pages, and none of them carries an `id`, so the extractor still offers the page label alone. The changeset gains `@objectstack/platform-objects` — its shipped Setup bundles carry the new `dashboards.system_overview.globalFilters.created_at.label` row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .changeset/i18n-slotted-pages-and-global-filters.md | 3 +++ .../platform-objects/scripts/i18n-extract.config.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md index 5bbf10893b..ce3c65802d 100644 --- a/.changeset/i18n-slotted-pages-and-global-filters.md +++ b/.changeset/i18n-slotted-pages-and-global-filters.md @@ -1,6 +1,7 @@ --- "@objectstack/spec": minor "@objectstack/cli": patch +"@objectstack/platform-objects": patch --- Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). @@ -13,3 +14,5 @@ Two surfaces the console renders that no translation bundle could address — a **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + +**`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index d8a8bf8662..138c1401d9 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -76,12 +76,15 @@ * the part worth writing down: measured through the real * `collectExpectedEntries`, the three offer exactly THREE keys between * them (one page-level `label` each). All three author `regions: []` and - * put every component under `slots.*`, and the shared walk + * put every component under `slots.*`. Since #16772 the shared walk * (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots at - * `regions[].components[]` -- so 45 further authored copy sites, every one - * an inline `{ en, 'zh-CN', ... }` locale map, have no bundle face to be - * counted against. A config-only change would declare pages the walk still - * cannot see: it would look like a fix and measure nothing. + * `slots.` as well as `regions[].components[]`, so it now REACHES + * every one of those components -- and not one of them carries an `id`, + * so none is addressable by `pages..components.`: the 45 + * further authored copy sites, every one an inline `{ en, 'zh-CN', ... }` + * locale map, still have no bundle face to be counted against. A + * config-only change would declare pages that offer nothing beyond their + * label: it would look like a fix and measure nothing. * * Their gate is `packages/cli/test/platform-page-i18n-parity.test.ts`, * which owns both halves from the other side -- a `pages.*` bundle entry From 2a347ac7dfa23e02060cce23b7525f9ba3e9a0be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:11:58 +0000 Subject: [PATCH 5/7] chore(spec): regenerate the protocol reference on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The os-regen driver merged `content/docs/references/api/protocol.mdx` with exit 0 while keeping one side; regenerating from the merged sources restores both — main's `droppedFields` prose (#16930) and this branch's `dashboards` row gaining its `globalFilters` continuation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --- content/docs/references/api/protocol.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4a6ea712e8..07ea246918 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -569,7 +569,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` From 7b942e760f318cfeb093bd0b5d9b63b7a609e734 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:21:42 +0000 Subject: [PATCH 6/7] fix(i18n): declare the walk's return-shape change and pin the dashboard filter emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset graded `@objectstack/cli` and `@objectstack/platform-objects` `patch` while the diff moves each package's `src/**` and the PR declares clause ②; the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) puts a purely additive widening of a published surface at `minor` or above, so both are raised. `walkAddressedPageComponents` is published and its return value changed shape, so the changeset now carries the `**BREAKING**` banner and exactly one ADR-0087 disposition, which are the only breaking-ness carriers during the launch window the level number cannot express. `collectExpectedEntries` gained a `globalFilters` emitter with no test: `check:i18n-walk-parity` measures at top-level group granularity and `dashboards` was already walked, so the sub-group could drift green. The new pin holds the `name`-keyed and `field`-keyed spellings, the option keys, the unkeyed filter that is passed over, and the `optionsFrom` filter whose label stays addressable while its fetched rows do not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --- .../i18n-slotted-pages-and-global-filters.md | 10 ++- packages/cli/test/i18n-extract.test.ts | 90 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md index ce3c65802d..261148b5c3 100644 --- a/.changeset/i18n-slotted-pages-and-global-filters.md +++ b/.changeset/i18n-slotted-pages-and-global-filters.md @@ -1,11 +1,13 @@ --- "@objectstack/spec": minor -"@objectstack/cli": patch -"@objectstack/platform-objects": patch +"@objectstack/cli": minor +"@objectstack/platform-objects": minor --- Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). +**BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. @@ -16,3 +18,7 @@ Two surfaces the console renders that no translation bundle could address — a **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + +**Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 78d91e0406..5e9468311f 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -275,6 +275,96 @@ describe('collectExpectedEntries', () => { }); }); +describe('collectExpectedEntries — dashboard global filters (#16772)', () => { + // The extractor's `globalFilters` emitter is the CLI half of the bundle + // group `dashboards..globalFilters.`. `check:i18n-walk-parity` + // measures at TOP-LEVEL group granularity and `dashboards` was already + // walked before this group existed, so nothing that gate sees changes when + // a sub-group emitter drifts — this pin is what holds it. + const dashboardConfig: any = { + dashboards: [ + { + name: 'ops', + label: 'Operations', + globalFilters: [ + { + // `name` present: it is the key, and `field` is NOT. + name: 'dept', + field: 'department', + label: 'Requesting Department', + options: [ + { value: 'eng', label: 'Engineering' }, + { value: 'sales', label: 'Sales' }, + ], + }, + { + // No `name`: keyed by `field`. Not a lenient fallback — + // `GlobalFilterSchema.name` is declared as defaulting to `field`, + // so this IS the filter's key everywhere the platform reads it. + field: 'created_at', + label: 'Date Range', + }, + { + // `optionsFrom` rows are fetched at runtime, so no option of this + // filter is addressable from a bundle. Its own `label` is authored + // text and stays addressable — the two halves are pinned apart. + name: 'owner', + field: 'owner_id', + label: 'Owner', + optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' }, + }, + { + // Neither key: `globalFilterKey` is undefined and the filter is + // passed over rather than offered under a made-up key. + label: 'Unkeyed', + options: [{ value: 'x', label: 'X' }], + }, + ], + }, + ], + }; + + const filterPaths = () => + collectExpectedEntries(dashboardConfig) + .map((e) => e.path.join('.')) + .filter((p) => p.startsWith('dashboards.ops.globalFilters.')) + .sort(); + + it('offers the filter label and every static option, keyed by `name` else `field`', () => { + expect(filterPaths()).toEqual([ + 'dashboards.ops.globalFilters.created_at.label', + 'dashboards.ops.globalFilters.dept.label', + 'dashboards.ops.globalFilters.dept.options.eng', + 'dashboards.ops.globalFilters.dept.options.sales', + 'dashboards.ops.globalFilters.owner.label', + ]); + }); + + it('carries the authored source values, and keys an option by its `value`', () => { + const byPath = Object.fromEntries( + collectExpectedEntries(dashboardConfig).map((e) => [e.path.join('.'), e.sourceValue]), + ); + expect(byPath['dashboards.ops.globalFilters.dept.label']).toBe('Requesting Department'); + expect(byPath['dashboards.ops.globalFilters.dept.options.eng']).toBe('Engineering'); + expect(byPath['dashboards.ops.globalFilters.dept.options.sales']).toBe('Sales'); + expect(byPath['dashboards.ops.globalFilters.created_at.label']).toBe('Date Range'); + }); + + it('offers nothing under a `field` a named filter overrode, and nothing for an unkeyed one', () => { + const paths = filterPaths(); + // `name: 'dept'` won, so the field spelling addresses nothing. + expect(paths).not.toContain('dashboards.ops.globalFilters.department.label'); + // The unkeyed filter contributes no entry at all, under any spelling. + expect(paths.some((p) => p.endsWith('.options.x'))).toBe(false); + }); + + it('offers no option key for an `optionsFrom` filter — the rows are fetched, not authored', () => { + const paths = filterPaths(); + expect(paths).toContain('dashboards.ops.globalFilters.owner.label'); + expect(paths.filter((p) => p.startsWith('dashboards.ops.globalFilters.owner.options.'))).toEqual([]); + }); +}); + describe('extractTranslations', () => { it('fills the default locale from schema and emits empty strings for other locales', () => { const { bundles, counts } = extractTranslations(config, { From 205fff6c74414e5268e96932d178295ab68b1224 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:36:23 +0000 Subject: [PATCH 7/7] docs(i18n): correct the walk-contract docblocks the widened walk left stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docblocks only — no behaviour change, no test change. F2 (`packages/cli/src/utils/i18n-extract.ts`, `emitPageComponentCopy`): the shared walk this function delegates to now roots at `regions[].components[]` AND `slots.` and descends `properties.children` AND a panel's `properties.items[].children`, so the three claims written against the old, narrower walk were false on this branch: the roots/descent "only" pair, the emission exception's REGION-LEVEL wording (it is any ROOT-LEVEL `page:header`, a region's entry or a slot's — the code already reads `!nested`), and the count of ways `@objectstack/lint`'s `walkPageComponents` is wider. Re-derived against `packages/lint/src/page-walk.ts`: lint roots at slots (`:166-173`), descends `items[].children` (`:126-133`), `children` (`:139-142`) and `body` / `footer` (`:144-145`); the resolver now shares the first three, so lint is wider in exactly two ways — `properties.body` and `properties.footer`. F3 (`packages/spec/src/system/i18n-resolver.ts`, `walkAddressedPageComponents`): the docblock said `children` and `items[].children` are "the slots the walk owns; everything else on the node is the visitor's". `walkComposition` is called with the ORIGINAL `component` (`:1851`) and rebuilds the WHOLE `items` array from that original's entries (`:1814-1822` — non-panel entries copied across verbatim), then `:1853` spreads it over `next.properties`, so a visitor's edit to any other `items[*]` key is overwritten. The behaviour is deliberate and no current visitor writes `items`; only the sentence was wrong, so only the sentence changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3 --- packages/cli/src/utils/i18n-extract.ts | 31 +++++++++++++---------- packages/spec/src/system/i18n-resolver.ts | 13 +++++++--- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 827213e6f4..158aceff6d 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -1055,25 +1055,30 @@ export function authorWarnedTranslationGroups(): ReadonlySet { * neither can drift. The KEY list is {@link PAGE_COMPONENT_COPY_KEYS}; the * WALK — which components carry those keys — is `walkAddressedPageComponents`, * the same traversal `translatePage` itself runs (#13218, completing the key - * list's precedent). The walk owns the roots (`regions[].components[]` only), - * the descent (`properties.children` only, depth-capped, cycle-guarded) and - * the ruled collision arbitration (#12961: region level wins outright; among - * nested components, document-order first sighting) — this function used to + * list's precedent). The walk owns the roots (`regions[].components[]` AND + * `slots.`), the descent (`properties.children` AND a panel's + * `properties.items[].children`, depth-capped, cycle-guarded) and the ruled + * collision arbitration (#12961: root level wins outright; among nested + * components, document-order first sighting) — this function used to * hand-mirror all five and now owns none of them. What it still owns: * - * - the emission exception: a REGION-LEVEL `page:header` emits nothing here - * (its copy is offered under `pages..title` / `.subtitle` instead — - * emitting both would offer one string under two keys), but the walk still - * counts its id as region-level, so a nested namesake stays blocked; + * - the emission exception: a ROOT-LEVEL `page:header` — a region's entry + * or a `slots.` entry — emits nothing here (its copy is offered + * under `pages..title` / `.subtitle` instead — emitting both would + * offer one string under two keys), but the walk still counts its id as + * root-level, so a nested namesake stays blocked; * - the `label` either/or: `label` may be authored on the component itself * or in its props — the same either/or `translatePage` resolves back onto. * * ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`, which is - * WIDER than the resolver in four ways (`slots.` roots, - * `properties.items[].children`, `properties.body`, `properties.footer`) and - * NARROWER in one (it skips `kind: 'html' | 'react' | 'jsx'` pages, which - * `translatePage` walks) — either direction of that mismatch is one half of - * the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names. + * WIDER than the resolver in two ways (`properties.body`, `properties.footer` + * — `page:card`'s slots, which the resolver leaves undescended as a renderer + * back-compat fallback rather than an authorable spelling; `slots.` + * roots and `properties.items[].children` were the other two until #16772 + * brought both into the shared walk) and NARROWER in one (it skips + * `kind: 'html' | 'react' | 'jsx'` pages, which `translatePage` walks) — + * either direction of that mismatch is one half of the failure pair + * `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names. */ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): void { walkAddressedPageComponents(page, (component, { id, nested, addressed }) => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a94f3ce15d..956b72dd5b 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1843,9 +1843,16 @@ export function walkAddressedPageComponents( // Pre-order: the visitor sees the parent before its children, so a // consumer that emits in visit order emits in document order. The rebuilt - // composition slots land on the RETURNED node afterwards — `children` and - // `items[].children` are the slots the walk owns; everything else on the - // node is the visitor's. + // composition slots land on the RETURNED node afterwards, and they are + // rebuilt from the ORIGINAL component, never from `next`. The walk owns + // two `properties` keys, each only when the ORIGINAL node carries it: + // `children` (rebuilt entry by entry), and — on a node carrying at least + // one panel (an `items` entry with a `children` array) — the WHOLE `items` + // array, panels rebuilt and every other entry carried across exactly as it + // was authored. So a visitor's edit to any other `items[*]` key (a panel's + // own `label`, say) is overwritten; on a node with no panel `items` is not + // rebuilt at all and such an edit stands. Everything else the visitor + // returns — every other `properties` key, every top-level key — is kept. let next = visit(component, { id, nested, depth, addressed }); const rebuilt = walkComposition(component, depth);