Skip to content
Draft
24 changes: 24 additions & 0 deletions .changeset/i18n-slotted-pages-and-global-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/spec": minor
"@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.<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.

- 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 }`.
- `translatePage` carries the rebuilt `slots` back onto the document.

**`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.

**`@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.

**`@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`.

**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.

<!-- 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 -->
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1575,7 +1575,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ Translation data for objects, apps, and UI messages
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
Expand Down Expand Up @@ -283,6 +283,7 @@ Translation data for a single object
| **description** | `string` | optional | Translated dashboard description |
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |
| **globalFilters** | `Record<string, { label?: string; options?: Record<string, string> }>` | 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]`

Expand Down Expand Up @@ -382,7 +383,7 @@ One locale of translations — the `translation` metadata type
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
Expand Down Expand Up @@ -443,6 +444,7 @@ Translation data for a single object
| **description** | `string` | optional | Translated dashboard description |
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |
| **globalFilters** | `Record<string, { label?: string; options?: Record<string, string> }>` | 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]`

Expand Down
4 changes: 3 additions & 1 deletion content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ export default defineStack({
| App navigation | `apps.<app>.navigation.<id>.label` |
| Dashboard label / description | `dashboards.<name>.label` / `description` |
| Dashboard widget title / description / sub-caption | `dashboards.<name>.widgets.<widgetId>.title` / `description` / `subCaption` |
| 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 |
| Analytics dataset label / description | `datasets.<name>.label` / `description` |
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` |
| 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 |
| 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` |
| 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 |
| 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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,4 @@ directory rather than per file.
| `kernel/` | 260 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 350 |
| `system/` | 351 |
28 changes: 28 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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' },
},
},
},
},
},
Expand Down Expand Up @@ -973,6 +987,20 @@ export const ShowcaseTranslationBundle = {
kpi_paid_rate: { title: '已付比例' },
table_rate_by_status: { title: '各状态已付比例' },
},
// Born under the ratchet with `dashboards.<name>.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.<name>.components`
Expand Down
37 changes: 30 additions & 7 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1321,6 +1322,26 @@ export function collectExpectedEntries(
pushEntry(out, ['dashboards', name, 'widgets', wid, 'description'], w.description, 'widget');
}
}
// Global-filter copy (#16772) — `dashboards.<name>.globalFilters.<key>`,
// 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.<name>.…`) ─────────────────────
Expand All @@ -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.<slot>` 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.
Expand All @@ -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
Expand Down
Loading
Loading