Skip to content

Commit d2fc4e8

Browse files
committed
wip(i18n): widen the shared page walk to slots + items[].children; add dashboards.*.globalFilters
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
1 parent c5ea982 commit d2fc4e8

9 files changed

Lines changed: 935 additions & 107 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
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).
7+
8+
**`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.<slot>`, so the walk visited nothing on it and `pages.<name>` 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.<slot>` (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.
9+
10+
- Signature: the parameter is `AddressedPageRoots` (= `Pick<PageLike, 'regions' | 'slots'>`) instead of `Pick<PageLike, 'regions'>`, 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 }`.
11+
- `translatePage` carries the rebuilt `slots` back onto the document.
12+
13+
**`dashboards.<name>.globalFilters.<key>` 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.<value>` 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.
14+
15+
**`@objectstack/cli`:** `os i18n extract` offers `dashboards.<name>.globalFilters.<key>.label` / `.options.<value>` for every static filter, and `pages.<name>.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.

content/docs/ui/translations.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ export default defineStack({
7474
| App navigation | `apps.<app>.navigation.<id>.label` |
7575
| Dashboard label / description | `dashboards.<name>.label` / `description` |
7676
| Dashboard widget title / description / sub-caption | `dashboards.<name>.widgets.<widgetId>.title` / `description` / `subCaption` |
77+
| Dashboard global-filter label and static option labels | `dashboards.<name>.globalFilters.<filterName>.label` / `.options.<value>` — 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 |
7778
| Analytics dataset label / description | `datasets.<name>.label` / `description` |
7879
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
79-
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` |
80+
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
81+
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
8082
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.label` / `.placeholder` — see the boundary note below |
8183
| Global actions, settings, messages | `globalActions`, `settings`, `messages` |
8284
| 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 |

packages/cli/src/utils/i18n-extract.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
PAGE_COMPONENT_COPY_KEYS,
128128
FLOW_SCREEN_COPY_KEYS,
129129
FLOW_SCREEN_FIELD_COPY_KEYS,
130+
globalFilterKey,
130131
walkAddressedPageComponents,
131132
} from '@objectstack/spec/system';
132133
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
@@ -1321,6 +1322,26 @@ export function collectExpectedEntries(
13211322
pushEntry(out, ['dashboards', name, 'widgets', wid, 'description'], w.description, 'widget');
13221323
}
13231324
}
1325+
// Global-filter copy (#16772) — `dashboards.<name>.globalFilters.<key>`,
1326+
// the filter bar drawn above the widget titles. The KEY is imported from
1327+
// `@objectstack/spec` (`name`, else `field`) so the extractor offers the
1328+
// entry `translateDashboard` reads and never a neighbour of it; an option
1329+
// is keyed by its `value` spelled as a string, the resolver's own
1330+
// spelling. `optionsFrom` options are fetched rows and have no key.
1331+
const globalFilters: any[] = Array.isArray(dash.globalFilters) ? dash.globalFilters : [];
1332+
for (const filter of globalFilters) {
1333+
if (!filter || typeof filter !== 'object') continue;
1334+
const key = globalFilterKey(filter);
1335+
if (key === undefined) continue;
1336+
pushEntry(out, ['dashboards', name, 'globalFilters', key, 'label'], filter.label, 'dashboard');
1337+
const options: any[] = Array.isArray(filter.options) ? filter.options : [];
1338+
for (const option of options) {
1339+
if (!option || typeof option !== 'object') continue;
1340+
const { value } = option;
1341+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') continue;
1342+
pushEntry(out, ['dashboards', name, 'globalFilters', key, 'options', String(value)], option.label, 'dashboard');
1343+
}
1344+
}
13241345
}
13251346

13261347
// ── Analytics datasets (`datasets.<name>.…`) ─────────────────────
@@ -1337,12 +1358,13 @@ export function collectExpectedEntries(
13371358
}
13381359
// Header copy is authored inside the page's `page:header` component but
13391360
// is addressed by page name — `translatePage` overlays it back onto every
1340-
// header in the page's regions.
1341-
const regions: any[] = Array.isArray(page.regions) ? page.regions : [];
1342-
for (const region of regions) {
1343-
const components: any[] = Array.isArray(region?.components) ? region.components : [];
1344-
for (const component of components) {
1345-
if (component?.type !== PAGE_HEADER_COMPONENT_TYPE) continue;
1361+
// ROOT-LEVEL header: a region's entry, or a `slots.<slot>` entry on a
1362+
// `kind: 'slotted'` page (#16772). Which components are root level is the
1363+
// shared walk's to say (`nested: false`), not a second loop's — the loop
1364+
// this replaced read `page.regions` by hand and would have offered
1365+
// nothing for the `slots.header` the resolver now translates.
1366+
walkAddressedPageComponents(page, (component, { nested }) => {
1367+
if (!nested && component?.type === PAGE_HEADER_COMPONENT_TYPE) {
13461368
const props = component.properties ?? {};
13471369
// `title` duplicating `label` is the common case and resolves via the
13481370
// label fallback — only emit it when the two genuinely differ.
@@ -1351,7 +1373,8 @@ export function collectExpectedEntries(
13511373
}
13521374
pushEntry(out, ['pages', name, 'subtitle'], props.subtitle, 'page');
13531375
}
1354-
}
1376+
return component;
1377+
});
13551378

13561379
// Per-component copy, addressed by the component's own id (#6080). Without
13571380
// this pass the face exists but nothing writes the skeleton, so a

packages/cli/test/platform-page-i18n-parity.test.ts

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,24 @@ const walkParityPage = (): Record<string, any> => ({
289289
null,
290290
],
291291
// NOT descended by `translatePage`: `body`/`footer` are a
292-
// renderer-side back-compat fallback, and `items[].children` sits
293-
// one level deeper than the slot the ruling names.
292+
// renderer-side back-compat fallback, not an authorable
293+
// composition spelling.
294294
body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }],
295295
footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }],
296-
items: [{ children: [{ id: 'tab_child', type: 'object-metric', properties: { title: 'Tab child' } }] }],
296+
// DESCENDED since #16772 — a `page:tabs` / `page:accordion`
297+
// panel's `items[].children`, one level below the container.
298+
items: [{ label: 'Panel', children: [{ id: 'tab_child', type: 'object-metric', properties: { title: 'Tab child' } }] }],
297299
},
298300
},
299301
],
300302
},
301303
],
302-
// NOT walked by `translatePage` at all — it maps `regions` only.
303-
slots: { aside: { id: 'slot_child', type: 'object-metric', properties: { title: 'Slot child' } } },
304+
// A ROOT since #16772 — a `kind: 'slotted'` page authors its components
305+
// here (one component or an array per slot); walked after the regions.
306+
slots: {
307+
aside: { id: 'slot_child', type: 'object-metric', properties: { title: 'Slot child' } },
308+
details: [{ id: 'slot_list_child', type: 'record:details', properties: { title: 'Slot list child' } }],
309+
},
304310
});
305311

306312
/** A container chain deeper than the resolver's descent cap. */
@@ -370,17 +376,46 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
370376
const page = walkParityPage();
371377
expect([...idsExtractorOffers(page)].sort()).toEqual([
372378
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
379+
'slot_child', 'slot_list_child', 'tab_child',
373380
]);
374-
// `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are
375-
// absent from BOTH sides — the shapes `translatePage` does not descend.
381+
// `card_body_child` and `card_footer_child` are absent from BOTH sides —
382+
// the shapes `translatePage` does not descend. `tab_child`, `slot_child`
383+
// and `slot_list_child` are present on BOTH sides since #16772 widened
384+
// the shared walk to `items[].children` and to the `slots.<slot>` roots.
376385
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
377386
// the ruling. `nested_header` stays: a `page:header` inside a container is
378387
// reached by the id route only, so the id key is the only key it has.
379388
expect([...idsResolverApplies(page)].sort()).toEqual([
380389
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
390+
'slot_child', 'slot_list_child', 'tab_child',
381391
]);
382392
});
383393

394+
it('offers and reads the page-name header route for a `slots.header` page:header — a slotted page has a header too (#16772)', () => {
395+
const page = {
396+
name: 'slotted_header_page',
397+
label: 'Contract',
398+
kind: 'slotted',
399+
regions: [],
400+
slots: {
401+
header: { id: 'hdr', type: 'page:header', properties: { title: 'Contract detail', subtitle: 'Lifecycle' } },
402+
},
403+
};
404+
const offered = collectExpectedEntries({ pages: [page] } as any)
405+
.filter((e) => e.path[0] === 'pages' && e.path[1] === page.name)
406+
.map((e) => e.path.slice(2).join('.'))
407+
.sort();
408+
// Page-name route offered; the id route NOT offered for a root-level
409+
// header, exactly as for a region-level one.
410+
expect(offered).toEqual(['label', 'subtitle', 'title']);
411+
412+
const bundle = {
413+
en: { pages: { slotted_header_page: { title: 'T::title', subtitle: 'T::subtitle', components: { hdr: { title: 'ID-ROUTE' } } } } },
414+
} as any;
415+
const out = translatePage(page as any, bundle, { locale: 'en' });
416+
expect(out.slots.header.properties).toEqual({ title: 'T::title', subtitle: 'T::subtitle' });
417+
});
418+
384419
// ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ──
385420
//
386421
// Maintainer 「同意」, option 1, verbatim: "The page-name route is canonical
@@ -756,22 +791,31 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => {
756791
expect(SHIPPED_LOCALES.length).toBeGreaterThan(1);
757792
});
758793

759-
it('records that the extractor reaches the page label and nothing under `slots`', () => {
760-
// A BOUNDARY PIN, not an endorsement. It states the measured fact that the
761-
// shared walk roots at `regions[].components[]` and these pages author
762-
// `regions: []`, so the 45 inline sites under `slots.*` have no bundle
763-
// face. If the walk is ever widened -- a maintainer decision open on
764-
// #14749 -- this reds, and the person widening it is told, at the exact
765-
// moment they can act on it, that these three pages gain a bundle surface
766-
// that needs entries and a coverage home. That notice is the whole value:
767-
// today the same change would land green over an unmeasured population.
794+
it('records that the extractor now reaches under `slots` — and that every site it reaches there is an inline locale map with no seed', () => {
795+
// A BOUNDARY PIN, not an endorsement — moved, not removed. Until #16772
796+
// this pinned `offered: ['label']`: the shared walk rooted at
797+
// `regions[].components[]`, these pages author `regions: []`, and the 45
798+
// inline sites under `slots.*` had no bundle face. #16772 widened the
799+
// walk to the `slots.<slot>` roots and to `items[].children`, so the
800+
// notice the old pin promised has fired, and this is the answer to it:
801+
// these pages' copy is authored as inline locale maps (the ruled route for
802+
// page copy, judged complete by the next case), so what the extractor
803+
// offers for them is a set of `inlineLocales` rows — authored-with-no-
804+
// seed, never a string to translate. The bundle surface they gained
805+
// therefore needs NO entries, and their coverage home stays this file.
806+
// What this pin holds: the reach is real (more than the label alone), and
807+
// it exposes no seeded string for a translator to be asked for.
768808
for (const page of RECORD_PAGES) {
769-
const offered = collectExpectedEntries({ pages: [page] } as any)
770-
.filter((e) => e.path[0] === 'pages' && e.path[1] === page.name)
771-
.map((e) => e.path.slice(2).join('.'))
772-
.sort();
773-
expect({ page: page.name, regions: page.regions, offered })
774-
.toEqual({ page: page.name, regions: [], offered: ['label'] });
809+
const entries = collectExpectedEntries({ pages: [page] } as any)
810+
.filter((e) => e.path[0] === 'pages' && e.path[1] === page.name);
811+
const offered = entries.map((e) => e.path.slice(2).join('.')).sort();
812+
expect(page.regions).toEqual([]);
813+
expect(offered).toContain('label');
814+
expect(offered.filter((k) => k.startsWith('components.')).length).toBeGreaterThan(0);
815+
const seeded = entries
816+
.filter((e) => e.path[2] === 'components' && e.inline !== undefined)
817+
.map((e) => e.path.slice(2).join('.'));
818+
expect({ page: page.name, seededUnderSlots: seeded }).toEqual({ page: page.name, seededUnderSlots: [] });
775819
}
776820
});
777821

packages/spec/liveness/translation.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"dashboards": {
5252
"status": "live",
5353
"verifiedAt": "2026-08-28",
54-
"evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDashboardAttr (`dashboards.<name>.label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupWidgetAttr (`dashboards.<name>.widgets.<id>.<attr>`, `subCaption` included); packages/spec/src/system/i18n-resolver.ts#translateDashboard",
54+
"evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDashboardAttr (`dashboards.<name>.label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupWidgetAttr (`dashboards.<name>.widgets.<id>.<attr>`, `subCaption` included); packages/spec/src/system/i18n-resolver.ts#translateGlobalFilter (`dashboards.<name>.globalFilters.<key>.label` / `.options.<value>`, keyed by `globalFilterKey` — `name`, else `field`; #16772); packages/spec/src/system/i18n-resolver.ts#translateDashboard",
5555
"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."
5656
},
5757
"datasets": {

0 commit comments

Comments
 (0)