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
10 changes: 10 additions & 0 deletions .changeset/30504-contributed-nav-packaged-app-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): contributed navigation entries are translated again, the whole Setup sidebar among them

Clause-②: no
Metadata-protocol: none

`getPackagedAppBase(name)` now answers the app's packaged declaration with the code layer's navigation contributions folded in, by the one fold every served app read applies (`SchemaRegistry.applyNavContributions`, ADR-0029 D7). Before, it answered the declaration alone, so `translateApp` read every contributed entry's id as one a `.custom` override authored (ADR-0146 D2), skipped the catalog and served the source text: on a `zh-CN` deployment every Setup leaf entry (Users, Permission Set Assignment, Sharing Rules, API Keys and the rest) read English beside Chinese group headings, although the catalog carries their keys. Contributed entries now read the catalog's translation; the served app's shape is unchanged, only label values move. A navigation node a `.custom` override authors is in no contribution, so it still reads only the pair's translations, else its own text.
33 changes: 29 additions & 4 deletions packages/metadata-protocol/src/protocol-discovery-and-bases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* dataset, app and book packaged bases (`getPackagedPageBase`,
* `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`), the
* served-override reading of a served document (`servedOverrideBaseOf`) and the
* pair's own translations (`getPairTranslationBundle`).
* pair's own translations (`getPairTranslationBundle`). [#30504] The app base
* carries the code layer's navigation contributions, folded once.
*
* A MOVE, not a redesign. `ProtocolDiscoveryAndBasesFacet` is a stateless facet over the host
* (ruling 6624345766): each static method below is the body of the class
Expand Down Expand Up @@ -273,9 +274,33 @@ export class ProtocolDiscoveryAndBasesFacet {
return host.packagedArtifactBase('dataset', name);
}

/** The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract. */
static getPackagedAppBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
return host.packagedArtifactBase('app', name);
/**
* The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract.
*
* [#30504, ADR-0029 D7] The app's packaged declaration WITH the navigation
* the code layer contributes to it, folded by the registry's one fold
* (`SchemaRegistry.applyNavContributions`, the fold every served app read
* applies). A contributed entry is packaged content, so `translateApp`
* must find it in the base: without it the translator reads the entry's
* id as one an override authored (ADR-0146 D2), the catalog is skipped,
* and every contributed entry (the whole Setup sidebar) is served in the
* source language. A node a `.custom` override authors is in no
* contribution, so it is still absent from the base and still reads only
* the pair's translations.
*/
static getPackagedAppBase(host: Pick<ProtocolHost, 'packagedArtifactBase' | 'engine'>, name: string): unknown {
const declared = host.packagedArtifactBase('app', name);
if (declared === undefined || declared === null) return declared;
const registry = (host.engine as { registry?: { applyNavContributions?: (app: unknown) => unknown } } | undefined)?.registry;
if (typeof registry?.applyNavContributions !== 'function') return declared;
try {
return registry.applyNavContributions(declared);
} catch {
// A fold over in-memory contributors; a failure here must never turn
// a served document into a 5xx. The declaration alone is the base
// this accessor answered before the fold was applied to it.
return declared;
}
}

/** The body of `ObjectStackProtocolImplementation#getPackagedBookBase`; the method's docblock there is its contract. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,88 @@ describe('[#30062] a declared override\'s label order: the packaged base and the
}
}
});

/**
* [#30504, ADR-0029 D7 · ADR-0146 D2] The packaged base of an APP carries the
* navigation the code layer contributes to it, so a contributed entry is
* judged against its own packaged label and the catalog translates it.
*
* Measured live before the fix: `GET /api/v1/meta/app/setup` under `zh-CN`
* served every Setup group heading in Chinese and every leaf entry in English,
* while the catalog carried the leaf keys (`nav_users` = 用户). The leaves are
* code-level `NavigationContribution`s, folded into the served app on read by
* `SchemaRegistry.applyNavContributions`; the base `getPackagedAppBase`
* answered was the app's declaration alone, which carries none of them. The
* translator reads an id the known base lacks as a node an override authored
* (`packagedNavNodes` answers `{}`), and such a node reads only the pair's
* translations, so every contributed leaf kept its English.
*
* Pinned on the #29613 boot, with and without a metadata service:
*
* - the packaged base carries the contributed entry, and the registry's own
* item is not mutated by the fold;
* - the base's own app, translated against that base, reads the contributed
* entry in `zh-CN` and keeps it in `en`;
* - on the pair's declared override, a node the OVERRIDE authored still reads
* only the pair's translations, else its own text (ADR-0146 D2), while the
* contributed entry the override is served with reads the catalog.
*/
const CONTRIBUTOR = 'com.example.security';
const CONTRIBUTED_LABEL = 'Users';
const AUTHORED_LABEL = 'Custom entry';
const navOf = (doc: any): Record<string, unknown> =>
Object.fromEntries(((doc?.navigation ?? []) as any[]).map((n) => [n.id, n.label]));
const NAV_CATALOG = {
'zh-CN': { apps: { showcase: { navigation: { nav_users: { label: '用户' }, nav_custom: { label: '基包译文' } } } } },
};

async function bootContributed(stores: Stores, withService: boolean) {
const booted = await bootSealed(stores, withService);
booted.engine.registerApp({
id: CONTRIBUTOR, name: 'security', version: '1.0.0', namespace: 'security',
navigationContributions: [{
app: 'showcase',
items: [{ id: 'nav_users', type: 'object', objectName: 'showcase_task', label: CONTRIBUTED_LABEL }],
}],
});
return booted;
}

describe('[#30504] an app\'s packaged base carries its contributed navigation, so the catalog translates a contributed entry', () => {
for (const withService of [false, true]) {
const shape = withService ? 'with a metadata service (a real boot)' : 'with no metadata service';

it(`${shape}: the base carries the contributed entry, and the base's own app reads it translated in zh-CN and unchanged in en`, async () => {
const { engine, protocol } = await bootContributed(new Map(), withService);
const base = protocol.getPackagedAppBase('showcase');
expect(navOf(base), 'the packaged base').toEqual({ nav_users: CONTRIBUTED_LABEL });
expect((engine.registry.getItem('app', 'showcase') as any)?.navigation, 'the registry item, unmutated').toBeUndefined();

const served = (await protocol.getMetaItem({ type: 'app', name: 'showcase' }))?.item;
expect(navOf(served), 'served').toEqual({ nav_users: CONTRIBUTED_LABEL });
const zh = translateMetadataDocument('app', served, NAV_CATALOG as any, { locale: 'zh-CN', packagedBase: base });
expect(navOf(zh), 'zh-CN').toEqual({ nav_users: '用户' });
const en = translateMetadataDocument('app', served, NAV_CATALOG as any, { locale: 'en', packagedBase: base });
expect(navOf(en), 'en').toEqual({ nav_users: CONTRIBUTED_LABEL });
});

it(`${shape}: on the pair's override, a node the override authored reads only the pair's translations (ADR-0146 D2) while the contributed entry reads the catalog`, async () => {
const { protocol } = await bootContributed(new Map(), withService);
await publishOverride(protocol, 'app', 'showcase', {
...appBody(EDITED),
navigation: [{ id: 'nav_custom', type: 'object', objectName: 'showcase_task', label: AUTHORED_LABEL }],
});
const served = (await protocol.getMetaItem({ type: 'app', name: 'showcase' }))?.item;
expect(protocol.servedOverrideBaseOf('app', served), 'the served override').toBe(BASE);
expect(navOf(served), 'served').toEqual({ nav_custom: AUTHORED_LABEL, nav_users: CONTRIBUTED_LABEL });

const base = protocol.getPackagedAppBase('showcase');
const translate = (pairBundle?: unknown) => navOf(translateMetadataDocument('app', served, NAV_CATALOG as any, {
locale: 'zh-CN', packagedBase: base, ...(pairBundle ? { pairBundle: pairBundle as any } : {}),
}));
expect(translate(), 'no pair translation').toEqual({ nav_custom: AUTHORED_LABEL, nav_users: '用户' });
const pair = { 'zh-CN': { apps: { showcase: { navigation: { nav_custom: { label: '自定义入口' } } } } } };
expect(translate(pair), 'the pair\'s translation').toEqual({ nav_custom: '自定义入口', nav_users: '用户' });
});
}
});
Loading