Skip to content

Commit 7b942e7

Browse files
committed
fix(i18n): declare the walk's return-shape change and pin the dashboard filter emitter
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
1 parent 2a347ac commit 7b942e7

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

.changeset/i18n-slotted-pages-and-global-filters.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
---
22
"@objectstack/spec": minor
3-
"@objectstack/cli": patch
4-
"@objectstack/platform-objects": patch
3+
"@objectstack/cli": minor
4+
"@objectstack/platform-objects": minor
55
---
66

77
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).
88

9+
**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.
10+
911
**`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.
1012

1113
- 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 }`.
@@ -16,3 +18,7 @@ Two surfaces the console renders that no translation bundle could address — a
1618
**`@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.
1719

1820
**`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards.<name>.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`.
21+
22+
**Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards.<name>.globalFilters.<key>` 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.
23+
24+
<!-- adr-0087: not-required (no-migration-prescription) nothing authorable moves: the bundle group is additive, no spec key is retired and no stored metadata shape changes; the one incompatible surface is a published function's TypeScript return type, which the compiler reports and no ledger entry can act on -->

packages/cli/test/i18n-extract.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,96 @@ describe('collectExpectedEntries', () => {
275275
});
276276
});
277277

278+
describe('collectExpectedEntries — dashboard global filters (#16772)', () => {
279+
// The extractor's `globalFilters` emitter is the CLI half of the bundle
280+
// group `dashboards.<name>.globalFilters.<key>`. `check:i18n-walk-parity`
281+
// measures at TOP-LEVEL group granularity and `dashboards` was already
282+
// walked before this group existed, so nothing that gate sees changes when
283+
// a sub-group emitter drifts — this pin is what holds it.
284+
const dashboardConfig: any = {
285+
dashboards: [
286+
{
287+
name: 'ops',
288+
label: 'Operations',
289+
globalFilters: [
290+
{
291+
// `name` present: it is the key, and `field` is NOT.
292+
name: 'dept',
293+
field: 'department',
294+
label: 'Requesting Department',
295+
options: [
296+
{ value: 'eng', label: 'Engineering' },
297+
{ value: 'sales', label: 'Sales' },
298+
],
299+
},
300+
{
301+
// No `name`: keyed by `field`. Not a lenient fallback —
302+
// `GlobalFilterSchema.name` is declared as defaulting to `field`,
303+
// so this IS the filter's key everywhere the platform reads it.
304+
field: 'created_at',
305+
label: 'Date Range',
306+
},
307+
{
308+
// `optionsFrom` rows are fetched at runtime, so no option of this
309+
// filter is addressable from a bundle. Its own `label` is authored
310+
// text and stays addressable — the two halves are pinned apart.
311+
name: 'owner',
312+
field: 'owner_id',
313+
label: 'Owner',
314+
optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' },
315+
},
316+
{
317+
// Neither key: `globalFilterKey` is undefined and the filter is
318+
// passed over rather than offered under a made-up key.
319+
label: 'Unkeyed',
320+
options: [{ value: 'x', label: 'X' }],
321+
},
322+
],
323+
},
324+
],
325+
};
326+
327+
const filterPaths = () =>
328+
collectExpectedEntries(dashboardConfig)
329+
.map((e) => e.path.join('.'))
330+
.filter((p) => p.startsWith('dashboards.ops.globalFilters.'))
331+
.sort();
332+
333+
it('offers the filter label and every static option, keyed by `name` else `field`', () => {
334+
expect(filterPaths()).toEqual([
335+
'dashboards.ops.globalFilters.created_at.label',
336+
'dashboards.ops.globalFilters.dept.label',
337+
'dashboards.ops.globalFilters.dept.options.eng',
338+
'dashboards.ops.globalFilters.dept.options.sales',
339+
'dashboards.ops.globalFilters.owner.label',
340+
]);
341+
});
342+
343+
it('carries the authored source values, and keys an option by its `value`', () => {
344+
const byPath = Object.fromEntries(
345+
collectExpectedEntries(dashboardConfig).map((e) => [e.path.join('.'), e.sourceValue]),
346+
);
347+
expect(byPath['dashboards.ops.globalFilters.dept.label']).toBe('Requesting Department');
348+
expect(byPath['dashboards.ops.globalFilters.dept.options.eng']).toBe('Engineering');
349+
expect(byPath['dashboards.ops.globalFilters.dept.options.sales']).toBe('Sales');
350+
expect(byPath['dashboards.ops.globalFilters.created_at.label']).toBe('Date Range');
351+
});
352+
353+
it('offers nothing under a `field` a named filter overrode, and nothing for an unkeyed one', () => {
354+
const paths = filterPaths();
355+
// `name: 'dept'` won, so the field spelling addresses nothing.
356+
expect(paths).not.toContain('dashboards.ops.globalFilters.department.label');
357+
// The unkeyed filter contributes no entry at all, under any spelling.
358+
expect(paths.some((p) => p.endsWith('.options.x'))).toBe(false);
359+
});
360+
361+
it('offers no option key for an `optionsFrom` filter — the rows are fetched, not authored', () => {
362+
const paths = filterPaths();
363+
expect(paths).toContain('dashboards.ops.globalFilters.owner.label');
364+
expect(paths.filter((p) => p.startsWith('dashboards.ops.globalFilters.owner.options.'))).toEqual([]);
365+
});
366+
});
367+
278368
describe('extractTranslations', () => {
279369
it('fills the default locale from schema and emits empty strings for other locales', () => {
280370
const { bundles, counts } = extractTranslations(config, {

0 commit comments

Comments
 (0)