Skip to content

Commit cdfc9ef

Browse files
committed
fix(spec): a page label does not fill the untitled header of a record page
On a `type: 'record'` page bound to an `object`, a `page:header` that authors no title and keeps its record chrome derives its heading from the record. `translatePage` no longer fills that absent title with the pack's `pages.NAME.label`; everywhere else the label still fills, and an authored title follows the existing order. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25be876 commit cdfc9ef

4 files changed

Lines changed: 214 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
A locale pack's page `label` no longer replaces the record-derived heading on a record page whose header authors no title
6+
7+
Clause-②: no
8+
9+
On a record page, a `page:header` with no `title` is the sanctioned spelling for "the renderer derives the heading from the record" (`PageHeaderProps.title`). Studio's record-page seed writes exactly that header. `translatePage` used to fill the absent title with the pack's `pages.<name>.label`, so the record's name was replaced by the page's static label. This happened in every locale whose pack carries the label, including the `en` skeleton that `os i18n extract` writes. Measured at `GET /api/v1/meta/page/:name` before this change: the header gained `title: "客户记录页"` in zh-CN, and `title: "Account Record"` in en with the extracted skeleton.
10+
11+
The label now does not fill an untitled root `page:header` when all three of these hold:
12+
13+
- the page's `type` is `'record'` as served (a document with no `type` is not a record page);
14+
- the page's `object` is a non-empty string;
15+
- the header keeps its record chrome (`properties.recordChrome` is not `false`).
16+
17+
Everywhere else the label still fills an absent header title, as before. An authored header title follows the existing order, including one that restates the page's `label`. A pack's `pages.<name>.title` and `subtitle` apply as before.

‎content/docs/ui/translations.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ When `title` is omitted, the header keeps the title its author wrote, and
131131
title restates the page's `label`. So a page whose header title matches its
132132
nav label needs only `label`, while a header whose title says something else,
133133
such as a dynamic `'{name}'`, keeps it until the bundle supplies a `title`.
134+
One exception: on a page of `type: 'record'` bound to an `object`, a header
135+
that authors no title and keeps its record chrome (`recordChrome` not
136+
`false`) derives its heading from the record, so `label` does not fill it and
137+
the record's own name shows in every locale.
134138
Every root-level `page:header` (a region's entry or a `slots.<slot>` entry)
135139
is resolved by this one rule.
136140
</Callout>

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2784,7 +2784,10 @@ describe('translatePage — a bundle label never replaces a header title that sa
27842784
expect(headerOf(root, out).title).toBe('客户 {name}');
27852785
});
27862786

2787-
it('CONTROL: a header with no authored title still shows the bundle `label`', () => {
2787+
// The fixture page carries no `type` and no `object`, so as served it is
2788+
// NOT a record page and its untitled header derives nothing from a
2789+
// record. On a record page the label does not fill (#22536, below).
2790+
it('CONTROL: a header with no authored title on a non-record page still shows the bundle `label`', () => {
27882791
for (const header of [{}, { title: '' }, { subtitle: 'Overview' }]) {
27892792
const out = translatePage(pageWith(root, header), labelOnly, { locale: 'zh-CN' });
27902793
expect({ header, title: headerOf(root, out).title }).toEqual({ header, title: '客户详情' });
@@ -2813,6 +2816,142 @@ describe('translatePage — a bundle label never replaces a header title that sa
28132816
});
28142817
});
28152818

2819+
// #22536: on a record page, a header with no title is the sanctioned spelling
2820+
// for "the renderer derives the heading from the record"
2821+
// (`PageHeaderProps.title`). Filling it with the bundle's page `label` put the
2822+
// page's static name in place of the record's, in every locale whose pack
2823+
// carries the label, the en skeleton `os i18n extract` writes included. The
2824+
// label now does not fill an untitled header when the page's `type` is
2825+
// `'record'` as served, its `object` is a non-empty string, and the header
2826+
// keeps its record chrome. Everywhere else it still fills, as before.
2827+
describe('translatePage — a bundle label does not fill the untitled header of a record page', () => {
2828+
const PAGE = 'account_record';
2829+
type Root = 'region' | 'slot';
2830+
const ROOTS: readonly Root[] = ['region', 'slot'];
2831+
2832+
/**
2833+
* The Studio seed's shape: objectui's `createSeed` writes `type: 'record'`,
2834+
* the chosen `object`, and the synthesized `page:header`, which carries
2835+
* `recordChrome: true` and no `title`.
2836+
*/
2837+
const SEED_PAGE: Record<string, unknown> = { label: 'Account Record', type: 'record', object: 'account' };
2838+
const SEED_HEADER = { recordChrome: true };
2839+
2840+
const pageWith = (
2841+
root: Root,
2842+
header: Record<string, unknown>,
2843+
page: Record<string, unknown> = SEED_PAGE,
2844+
): any => {
2845+
const component = { type: 'page:header', properties: header };
2846+
return root === 'region'
2847+
? { name: PAGE, ...page, regions: [{ name: 'main', components: [component] }] }
2848+
: { name: PAGE, ...page, kind: 'slotted', regions: [], slots: { header: component } };
2849+
};
2850+
const headerOf = (root: Root, page: any): Record<string, unknown> =>
2851+
root === 'region' ? page.regions[0].components[0].properties : page.slots.header.properties;
2852+
2853+
/** A zh-CN pack carrying the page label and nothing else. */
2854+
const zhLabelOnly: TranslationBundle = {
2855+
'zh-CN': { pages: { [PAGE]: { label: '客户记录页' } } },
2856+
};
2857+
/** The en skeleton `os i18n extract` writes: the authored label, restated. */
2858+
const enSkeleton: TranslationBundle = {
2859+
en: { pages: { [PAGE]: { label: 'Account Record' } } },
2860+
};
2861+
const CASES = [
2862+
{ pack: 'zh-CN label only', bundle: zhLabelOnly, locale: 'zh-CN', label: '客户记录页' },
2863+
{ pack: 'en extract skeleton', bundle: enSkeleton, locale: 'en', label: 'Account Record' },
2864+
] as const;
2865+
2866+
for (const root of ROOTS) {
2867+
describe(`at a ${root} root`, () => {
2868+
for (const { pack, bundle, locale, label } of CASES) {
2869+
it(`keeps the Studio-seeded record header untitled with the ${pack} pack`, () => {
2870+
const out = translatePage(pageWith(root, SEED_HEADER), bundle, { locale });
2871+
expect(headerOf(root, out)).toEqual({ recordChrome: true });
2872+
expect('title' in headerOf(root, out)).toBe(false);
2873+
// The page's own label still translates; only the header stays as authored.
2874+
expect(out.label).toBe(label);
2875+
});
2876+
}
2877+
2878+
it('keeps an untitled header untitled when `recordChrome` is left at its default', () => {
2879+
const headers: Array<Record<string, unknown>> = [{}, { title: '' }];
2880+
for (const header of headers) {
2881+
const out = translatePage(pageWith(root, header), zhLabelOnly, { locale: 'zh-CN' });
2882+
expect(headerOf(root, out)).toEqual(header);
2883+
}
2884+
});
2885+
2886+
it('still applies the bundle subtitle beside the untitled header', () => {
2887+
const subtitled: TranslationBundle = {
2888+
'zh-CN': { pages: { [PAGE]: { label: '客户记录页', subtitle: '客户概览' } } },
2889+
};
2890+
const out = translatePage(pageWith(root, SEED_HEADER), subtitled, { locale: 'zh-CN' });
2891+
expect(headerOf(root, out)).toEqual({ recordChrome: true, subtitle: '客户概览' });
2892+
});
2893+
2894+
// One control per condition: drop any one of the three and the header
2895+
// derives nothing from a record, so the label fills it as before.
2896+
it('CONTROL: a page with no `type` is not a record page as served, and the label fills', () => {
2897+
const untyped = { label: SEED_PAGE.label, object: SEED_PAGE.object };
2898+
const out = translatePage(pageWith(root, SEED_HEADER, untyped), zhLabelOnly, { locale: 'zh-CN' });
2899+
expect(headerOf(root, out).title).toBe('客户记录页');
2900+
});
2901+
2902+
it('CONTROL: a record page with no `object`, or an empty one, has no record to derive from, and the label fills', () => {
2903+
const unbound = { label: SEED_PAGE.label, type: SEED_PAGE.type };
2904+
const pages: Array<Record<string, unknown>> = [unbound, { ...SEED_PAGE, object: '' }];
2905+
for (const page of pages) {
2906+
const out = translatePage(pageWith(root, SEED_HEADER, page), zhLabelOnly, { locale: 'zh-CN' });
2907+
expect({ object: page.object, title: headerOf(root, out).title })
2908+
.toEqual({ object: page.object, title: '客户记录页' });
2909+
}
2910+
});
2911+
2912+
it('CONTROL: a record-page header with `recordChrome: false` draws the bare heading, and the label fills', () => {
2913+
const out = translatePage(pageWith(root, { recordChrome: false }), zhLabelOnly, { locale: 'zh-CN' });
2914+
expect(headerOf(root, out)).toEqual({ recordChrome: false, title: '客户记录页' });
2915+
});
2916+
2917+
it('CONTROL: an object-bound page of another `type` derives nothing from a record, and the label fills', () => {
2918+
for (const type of ['list', 'app', 'home', 'utility']) {
2919+
const out = translatePage(pageWith(root, SEED_HEADER, { ...SEED_PAGE, type }), zhLabelOnly, { locale: 'zh-CN' });
2920+
expect({ type, title: headerOf(root, out).title }).toEqual({ type, title: '客户记录页' });
2921+
}
2922+
});
2923+
2924+
// An authored title on a record page follows #22508 unchanged.
2925+
it('an authored template title on a record page is kept against a label-only pack', () => {
2926+
const out = translatePage(pageWith(root, { ...SEED_HEADER, title: '{name}' }), zhLabelOnly, { locale: 'zh-CN' });
2927+
expect(headerOf(root, out).title).toBe('{name}');
2928+
});
2929+
2930+
it('an authored title on a record page that restates the page label takes the label translation', () => {
2931+
const out = translatePage(pageWith(root, { ...SEED_HEADER, title: 'Account Record' }), zhLabelOnly, { locale: 'zh-CN' });
2932+
expect(headerOf(root, out).title).toBe('客户记录页');
2933+
});
2934+
2935+
it('a pack with `title` replaces an authored title on a record page', () => {
2936+
const withTitle: TranslationBundle = {
2937+
'zh-CN': { pages: { [PAGE]: { label: '客户记录页', title: '客户 {name}' } } },
2938+
};
2939+
const out = translatePage(pageWith(root, { ...SEED_HEADER, title: '{name}' }), withTitle, { locale: 'zh-CN' });
2940+
expect(headerOf(root, out).title).toBe('客户 {name}');
2941+
});
2942+
});
2943+
}
2944+
2945+
it('serves the same answer through `translateMetadataDocument`, the function the REST page doors call', () => {
2946+
for (const root of ROOTS) {
2947+
for (const { bundle, locale } of CASES) {
2948+
const out = translateMetadataDocument('page', pageWith(root, SEED_HEADER), bundle, { locale });
2949+
expect({ root, locale, header: headerOf(root, out) }).toEqual({ root, locale, header: { recordChrome: true } });
2950+
}
2951+
}
2952+
});
2953+
});
2954+
28162955
describe('translatePage — filter-preset tab bar', () => {
28172956
const bundle: TranslationBundle = {
28182957
'zh-CN': {

‎packages/spec/src/system/i18n-resolver.ts‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,8 +1884,14 @@ function sameAuthoredText(a: string | Record<string, string>, b: unknown): boole
18841884
* 2. the header's AUTHORED title, left as written: a template such as
18851885
* `'{name}'`, a plain string, or an inline locale map;
18861886
* 3. the bundle's `pages.<name>.label`, in two cases only: the header authors
1887-
* no title, or its title restates the page's own authored `label`. In the
1888-
* second case the label's translation IS a translation of that text, and
1887+
* no title AND does not derive its heading from a record
1888+
* ({@link headerDerivesFromRecord}), or its title restates the page's own
1889+
* authored `label`. In the first case's exception, an absent title IS the
1890+
* authored value: `PageHeaderProps.title` makes "omitted" the sanctioned
1891+
* spelling for "the renderer derives the heading from the record", so the
1892+
* label filling it would put the page's static name in place of the
1893+
* record's, and the title stays absent (#22536). In the second case the
1894+
* label's translation IS a translation of that text, and
18891895
* `os i18n extract` offers no `title` key for such a header (it offers the
18901896
* label alone), so reading the label here is what translates it.
18911897
*
@@ -1896,14 +1902,53 @@ function resolvePageHeaderTitle(
18961902
bundleLabel: string | undefined,
18971903
authoredTitle: unknown,
18981904
authoredPageLabel: unknown,
1905+
derivesFromRecord: boolean,
18991906
): string | undefined {
19001907
if (bundleTitle !== undefined) return bundleTitle;
19011908
if (bundleLabel === undefined) return undefined;
19021909
const own = authoredLabelText(authoredTitle);
1903-
if (own === undefined || sameAuthoredText(own, authoredPageLabel)) return bundleLabel;
1910+
if (own === undefined) return derivesFromRecord ? undefined : bundleLabel;
1911+
if (sameAuthoredText(own, authoredPageLabel)) return bundleLabel;
19041912
return undefined;
19051913
}
19061914

1915+
/**
1916+
* Does this root-level `page:header` derive its heading from a record? The
1917+
* test step 3 of {@link resolvePageHeaderTitle} reads (#22536), and all three
1918+
* conditions must hold:
1919+
*
1920+
* - the page's `type` is `'record'`, read as SERVED. A document with no
1921+
* `type` is not a record page to the hosts below, so it is not one here
1922+
* either, although `PageSchema` defaults an absent `type` to `'record'`
1923+
* at parse;
1924+
* - the page's `object` is a non-empty string;
1925+
* - the header does not opt out of the record chrome
1926+
* (`properties.recordChrome !== false`).
1927+
*
1928+
* The renderer's own test is runtime, not a property of this document.
1929+
* objectui's `PageHeaderRenderer` draws the record-derived heading when a
1930+
* record context is mounted (`ctx.data` and `ctx.objectSchema`) and the
1931+
* header keeps its record chrome. The first two conditions are when the
1932+
* record hosts mount that context: the record route mounts it for the page
1933+
* `usePageAssignment` picks (`type === 'record'` and `object` naming the
1934+
* route's object), and the Studio preview mounts it for a `type: 'record'`
1935+
* page with an `object`. The third is the renderer's own switch, which it
1936+
* reads off the header node. Only `properties.recordChrome` is read:
1937+
* `PageComponentSchema` is strict and refuses a bare top-level
1938+
* `recordChrome` on a component, so a valid document never carries that
1939+
* spelling.
1940+
*
1941+
* One host is not visible from here, and that gap is accepted. A
1942+
* `type: 'modal'` action renders the page it targets inside the record
1943+
* context of the page it was invoked from, whatever that page's `type` is.
1944+
* Such a page keeps the label fill.
1945+
*/
1946+
function headerDerivesFromRecord(page: PageLike, header: PageComponentLike): boolean {
1947+
return page.type === 'record'
1948+
&& typeof page.object === 'string' && page.object.length > 0
1949+
&& header.properties?.recordChrome !== false;
1950+
}
1951+
19071952
/**
19081953
* The copy keys `pages.<name>.components.<id>` carries (#6080). Measured
19091954
* against `ComponentPropsMap` — see the schema's own note for which component
@@ -2265,7 +2310,10 @@ export function walkAddressedPageComponents(
22652310
* authors no title or its title restates the page's own `label`, so a
22662311
* translator need not repeat a string identical to the page's nav label
22672312
* ({@link resolvePageHeaderTitle}). A label never replaces a header title
2268-
* that says something else, a dynamic `'{name}'` above all.
2313+
* that says something else, a dynamic `'{name}'` above all. Nor does it fill
2314+
* the absent title of a header that derives its heading from a record
2315+
* ({@link headerDerivesFromRecord}): on a `type: 'record'` page bound to an
2316+
* object, "no title" means "the record's own name".
22692317
*
22702318
* **One component, one address.** A ROOT-LEVEL `page:header` — an entry of a
22712319
* region's `components[]`, or of a `slots.<slot>` on a `kind: 'slotted'` page
@@ -2421,6 +2469,7 @@ export function translatePage<T extends PageLike>(
24212469
// `nested: false`, so the fallback order cannot differ between them.
24222470
const headerTitle = resolvePageHeaderTitle(
24232471
bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label,
2472+
headerDerivesFromRecord(doc, next),
24242473
);
24252474
if (headerTitle === undefined && headerSubtitle === undefined) return next;
24262475
return {

0 commit comments

Comments
 (0)