Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/24968-page-tabs-item-label-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/cli': minor
---

feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label`

Clause-②: yes (widening)

- **Before.** `pages.<page>.components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
- **Now.** `pages.<page>.components.<tabsId>.items.<value>.label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
- **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
- **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
- **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
1 change: 1 addition & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default defineStack({
| 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` — 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` |
| Page tab labels (`page:tabs` items) | `pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
| Global actions, messages | `globalActions`, `messages` |
| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 376 |
| `system/` | 377 |
19 changes: 19 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
* flows.<flow>.label
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
* flows.<flow>.screens.<node_id>.description (#22507)
Expand Down Expand Up @@ -137,7 +138,9 @@ import {
FLOW_SCREEN_COPY_KEYS,
FLOW_SCREEN_FIELD_COPY_KEYS,
FLOW_TERMINAL_MESSAGE_KEYS,
PAGE_TABS_COMPONENT_TYPE,
globalFilterKey,
pageTabItemKey,
walkAddressedPageComponents,
} from '@objectstack/spec/system';
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
Expand Down Expand Up @@ -1169,6 +1172,22 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
// records nothing (#14749).
pushEntry(out, ['pages', name, 'components', id as string, key], value, 'page');
}
// A `page:tabs` component's tab labels, keyed by each item's `value`
// under the tabs component's id — the key `translatePage` reads. An
// item without a `value` gets no slot: its renderer token is
// index-derived, and an array index is never a bundle key (#14749).
if (component.type === PAGE_TABS_COMPONENT_TYPE && Array.isArray(props.items)) {
for (const item of props.items as unknown[]) {
const value = pageTabItemKey(item);
if (value === undefined) continue;
pushEntry(
out,
['pages', name, 'components', id as string, 'items', value, 'label'],
(item as Record<string, unknown>).label,
'page',
);
}
}
}
return component;
});
Expand Down
111 changes: 111 additions & 0 deletions packages/cli/test/i18n-page-tab-item-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// A `page:tabs` item's `label` had no translation key. `pages.<page>.components`
// is keyed by component id, a tab item is not a component and carries no id,
// and `translatePage` never read `items[].label`, so a localized page kept its
// tab strip in the authoring language (measured on HotCRM's
// `sales_home_page`: three English tabs under a translated header).
//
// The key is `pages.<page>.components.<tabsId>.items.<value>.label`: the tabs
// component's own id, then the item's `value`, the stable `?tab=` token the
// renderer already reads. Unlike a key with no reader, `translatePage` reads
// this one, so the extractor offers the slot and coverage demands it. An item
// without a `value` gets no key: its renderer token is index-derived, and an
// array index is never a bundle key (the identity-first direction of the
// #14749 ruling).

import { describe, it, expect } from 'vitest';
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
import { TranslationDataSchema, translatePage } from '@objectstack/spec/system';

const homePage = () => ({
name: 'sales_home_page',
label: 'Sales Home',
regions: [{
name: 'main',
components: [{
type: 'page:tabs',
id: 'home_tabs',
properties: {
items: [
{ label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] },
{ label: { en: 'My Open Deals', 'zh-CN': '我的商机' }, value: 'my_deals', children: [] },
// No `value`: no key, whatever its label says.
{ label: 'My Open Tasks', children: [] },
] as any[],
},
}],
}],
});

/** Every `…components.<id>.items.*` path the walker emits, as dot-paths. */
const itemEntries = (config: any) =>
collectExpectedEntries(config).filter((e) => e.path[0] === 'pages' && e.path[4] === 'items');

describe('the extractor scaffolds a key for every valued tab item', () => {
it('emits `pages.<page>.components.<tabsId>.items.<value>.label`, and nothing for an item without a `value`', () => {
expect(itemEntries({ pages: [homePage()] }).map((e) => e.path.join('.'))).toEqual([
'pages.sales_home_page.components.home_tabs.items.my_leads.label',
'pages.sales_home_page.components.home_tabs.items.my_deals.label',
]);
});

it('seeds a plain label, and records an inline locale map as authored with no seed', () => {
const [leads, deals] = itemEntries({ pages: [homePage()] });
expect(leads.sourceValue).toBe('My Leads');
expect(deals.sourceValue).toBeUndefined();
expect(deals.inlineLocales).toEqual({ en: 'My Open Deals', 'zh-CN': '我的商机' });
});

it('CONTROL: the panel children keep their component keys', () => {
const paths = collectExpectedEntries({ pages: [homePage()] }).map((e) => e.path.join('.'));
expect(paths).toContain('pages.sales_home_page.components.home_my_leads.title');
});

it('emits nothing for `items` on a component that is not `page:tabs`', () => {
const page = homePage();
(page.regions[0].components[0] as any).type = 'page:accordion';
expect(itemEntries({ pages: [page] })).toEqual([]);
});

it('emits nothing for a tabs component with no id', () => {
const page = homePage();
delete (page.regions[0].components[0] as any).id;
expect(itemEntries({ pages: [page] })).toEqual([]);
});
});

describe('the emitted key is the key the schema declares and the resolver reads', () => {
it('`TranslationDataSchema` accepts a bundle written at the extracted path, and `translatePage` applies it', () => {
const out = extractTranslations({ pages: [homePage()] }, { locales: ['zh-CN'] });
const zh = out.bundles['zh-CN'] as any;
expect(zh?.pages?.sales_home_page?.components?.home_tabs?.items?.my_leads).toBeDefined();

const data = { pages: { sales_home_page: { components: { home_tabs: { items: { my_leads: { label: '我的线索' } } } } } } };
expect(TranslationDataSchema.safeParse(data).success).toBe(true);
const translated: any = translatePage(homePage(), { 'zh-CN': data } as any, { locale: 'zh-CN' });
expect(translated.regions[0].components[0].properties.items[0].label).toBe('我的线索');
});
});

describe('coverage', () => {
const config = (items: Record<string, unknown>) => ({
pages: [homePage()],
i18n: { supportedLocales: ['en', 'zh-CN'] },
translations: [{ 'zh-CN': { pages: { sales_home_page: { components: { home_tabs: { items } } } } } }],
});

const itemIssues = (report: { issues: Array<{ key: string }> }) =>
report.issues.filter((i) => i.key.includes('.items.')).map((i) => i.key);

it('reports an untranslated tab label', () => {
expect(itemIssues(computeI18nCoverage(config({})))).toEqual([
'pages.sales_home_page.components.home_tabs.items.my_leads.label',
]);
});

it('stays quiet once the pack keys it — and about the inline-map item that covers its locale', () => {
expect(itemIssues(computeI18nCoverage(config({ my_leads: { label: '我的线索' } })))).toEqual([]);
});
});
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,113 @@ describe('validateTranslationReferences — flow approval steps (`approvals.<nod
});
});

describe('validateTranslationReferences — `page:tabs` item labels (`components.<tabsId>.items.<value>.label`)', () => {
/**
* HotCRM's measured shape: a home page whose `page:tabs` holds the three
* tabs, beside an accordion and a related list, so each verdict below is
* judged against the same metadata as the clean run and differs only in
* the bundle.
*/
const homeStack = {
pages: [
{
name: 'sales_home_page',
label: 'Sales Home',
regions: [{
name: 'main',
components: [
{
type: 'page:tabs',
id: 'home_tabs',
properties: {
items: [
{ label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] },
{ label: 'My Open Deals', value: 'my_deals', children: [] },
{ label: 'My Open Tasks', children: [] },
],
},
},
{ type: 'page:accordion', id: 'faq', properties: { items: [{ label: 'Pricing', children: [] }] } },
],
}],
},
],
};
const bundle = (pages: unknown) => ({ ...homeStack, translations: [{ 'zh-CN': { pages } }] });
const tabs = (items: unknown, extra: Record<string, unknown> = {}) =>
bundle({ sales_home_page: { components: { home_tabs: { items }, ...extra } } });

it('reports nothing for the label of a valued tab item', () => {
expect(validateTranslationReferences(tabs({ my_leads: { label: '我的线索' }, my_deals: { label: '我的商机' } }))).toEqual([]);
});

it('controls: the component keys of the same page still resolve beside it, judged by nothing new', () => {
expect(
validateTranslationReferences(
tabs({ my_leads: { label: '我的线索' } }, { home_my_leads: { title: '线索' }, faq: { label: '常见问题' } }),
),
).toEqual([]);
});

it('refuses an item key over an UNKNOWN value, with the nearest tab value offered', () => {
const findings = validateTranslationReferences(tabs({ my_lead: { label: 'x' } }));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.my_lead');
expect(findings[0].message).toBe(
'Translations are keyed to tab "my_lead", which `page:tabs` "home_tabs" on page "sales_home_page" gives ' +
'no item as its `value`, so the tab keeps its source-locale label. Did you mean "my_leads"?',
);
expect(findings[0].hint).toBe(
"Tab-item translations are keyed by the item's `value`; give the item a `value` if it has none.",
);
});

it('refuses an item key over an index token, which no key ever reaches', () => {
const findings = validateTranslationReferences(tabs({ 'tab-2': { label: 'x' } }));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.tab-2');
});

it('refuses `items` on a `page:accordion`, pointing at the inline locale map', () => {
const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { faq: { items: { pricing: { label: 'x' } } } } } }));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.faq.items');
expect(findings[0].message).toBe(
'Translations carry tab items for "faq", which page "sales_home_page" declares as a `page:accordion`, ' +
'whose panels have no stable key, so nothing resolves them.',
);
expect(findings[0].hint).toBe('Localize an accordion panel label in place, as an inline locale map on `items[].label`.');
});

it('refuses `items` on a component of another type, by name', () => {
const findings = validateTranslationReferences(
bundle({ sales_home_page: { components: { home_my_leads: { items: { leads: { label: 'x' } } } } } }),
);
expect(findings).toHaveLength(1);
expect(findings[0].message).toBe(
'Translations carry tab items for "home_my_leads", which page "sales_home_page" declares as a ' +
'`record:related_list`, not a `page:tabs`, so nothing resolves them.',
);
expect(findings[0].hint).toBe('Tab-item translations go under the `id` of a `page:tabs` component.');
});

it('refuses `items` under a component id the page does not declare', () => {
const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { home_tab: { items: { my_leads: { label: 'x' } } } } } }));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tab.items');
expect(findings[0].message).toBe(
'Translations carry tab items for "home_tab", a component id page "sales_home_page" does not declare, ' +
'so nothing resolves them. Did you mean "home_tabs"?',
);
});

it('control: a page this stack does not declare is not judged by this check', () => {
expect(validateTranslationReferences(bundle({ other_page: { components: { t: { items: { a: { label: 'x' } } } } } }))).toEqual([]);
});
});

describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
/**
* hotclm's measured shape, trimmed: a screen flow that authors a completion
Expand Down Expand Up @@ -4035,6 +4142,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
'`resultDialog.fields` does not declare',
'declares no `fields` (it renders the whole response as JSON)',
'Translations are keyed to picklist "',
'gives no item as its `value`',
'whose panels have no stable key',
'not a `page:tabs`, so nothing resolves them',
'a component id page "',
];
const OPTION_ARMS = [
'which declares no `options` at all (field type',
Expand Down Expand Up @@ -4186,6 +4297,9 @@ describe('[#22161] one-line fixes — translation-target-unknown', () => {
'Move the `resultDialog` translations under the action',
"Match the key to a declared field's literal `path`",
"Match the key to a picklist's `name`",
"Tab-item translations are keyed by the item's `value`",
'Localize an accordion panel label in place',
'Tab-item translations go under the `id` of a `page:tabs` component',
];

it('every `fix:` line the cases above fired is one line of at most 200 characters', () => {
Expand Down
Loading
Loading