diff --git a/.changeset/30391-extension-rename-drops-stale-plural.md b/.changeset/30391-extension-rename-drops-stale-plural.md new file mode 100644 index 00000000000..0535257e983 --- /dev/null +++ b/.changeset/30391-extension-rename-drops-stale-plural.md @@ -0,0 +1,20 @@ +--- +'@objectstack/objectql': minor +'@objectstack/rest': minor +--- + +fix(objectql,rest)!: an object extension that renames `label` and sets no `pluralLabel` is served without the base's plural, so list titles stop showing the old name (#30391) + +Clause-②: no (narrowing) — `/meta/object` (item and list reads, both transports) and every registry read of an object (`SchemaRegistry.getObject`, the fold) stop serving `pluralLabel` for an object an extension renames without a plural of its own, where they served the base's plural (and, in a translated locale, the base's translation of it). The shipped showcase's `showcase_account` is one: its extension renames it `Account (Success Overlay)` and declares no plural. Nothing an author writes is refused. +Metadata-protocol: semantic +Skills-docs: follow-up — `skills/objectstack-data/SKILL.md` "Object Extension Model" says an extension "can add but never remove"; a renaming `label` now takes the base's `pluralLabel` off. The sentence wants that exception (outside this change's surface). + + + +**BREAKING** served-value narrowing for an object an extension renames. + +- **Before.** An `objectExtensions` entry or a `.custom` `object_extension` item that set `label` and no `pluralLabel` was folded with the new label and the base's `pluralLabel`. A translated read served the base package's translation of that plural (`objects.NAME.pluralLabel`), so a list title, read plural first, kept the old name in every locale. +- **Now.** `pluralLabel` is the plural of `label`: such an extension is served as an object that declares no `pluralLabel`, in every locale, so a list titles it by its label. Where the owning package's `.custom` pair saves its own translation of `objects.NAME.pluralLabel`, that translation is served (ruling record 6631250894: a translation in the pair, then the override's own text, then the base's translation). +- **Unchanged.** An extension that sets `pluralLabel` is served with it. One that sets no `label`, or repeats the label it extends, keeps the base's plural and its translation. A plural the tenant set stays, and a tenant's own rename yields as before (ADR-0029 D9.2a). An object no extension renames is served as before. The packaged base the translators compare against keeps its plural. + +Nothing has to be rewritten: an extension that wants a plural for its new name declares `pluralLabel` beside `label`, as it always could, and a `.custom` pair can save `objects.NAME.pluralLabel` in its own `translation` item for a translated locale. diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index dd923d082e0..b892c60b09a 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -66,7 +66,7 @@ check that spelling first. | `fieldPatches` | **Applied, field by field.** Changes the *display* of one of the target's own fields — never one an extension adds — without replacing it — [below](#relabelling-a-vendor-field). | | `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. | | `indexes` | **Concatenated**, same as validations. | -| `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). | +| `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). A new `label` with no `pluralLabel` beside it also takes the target's plural off, since that plural names the old label: the object is served as one that declares no plural, and lists title it by its label. Set `pluralLabel` too to serve one. A plural the tenant set stays. | | `pluralLabel` | Same as `label`. | | `description` | Same as `label`. | | `priority` | Not merged — it *orders* the merge. `0`–`999`, default `200`. | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 4269bfa009f..91e3e297f30 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -235,7 +235,10 @@ in no package. `GET /meta/:type/:name`, `GET /meta/:type` and the book tree Objects follow the same rule for their `label`, `pluralLabel` and `description`: a value that differs from the owning package's declaration (a label an [object extension](/docs/data-modeling/object-extensions) contributes, -for one) is served as written. +for one) is served as written. An extension that renames the object and sets +no `pluralLabel` is served with no plural in every locale, since the catalog's +`pluralLabel` translates the old name, unless the owning package's `.custom` +pair saves a translation of `objects..pluralLabel`: that one is served. An object's validation rule messages follow it too, on both places a reader sees them: the error a rejected write returns, and the rule served with the diff --git a/packages/objectql/src/registry-extension-plural-label.test.ts b/packages/objectql/src/registry-extension-plural-label.test.ts new file mode 100644 index 00000000000..7bcfc0d5855 --- /dev/null +++ b/packages/objectql/src/registry-extension-plural-label.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30391 — `pluralLabel` is the plural OF `label`. An object extension that + * renames the object (`label`) and declares no `pluralLabel` leaves the running + * plural naming the old label, so the fold (`mergeObjectDefinitions`) takes it + * off and the object is served as one that declares no `pluralLabel`. + * + * What stays: an extension's own plural; the base's plural under an extension + * that renames nothing (no `label`, or the same one); a plural the tenant + * authored (the ADR-0029 D9.2a yield, which a tenant-authored `label` also + * takes); the packaged base the translators compare against. + * + * The served answer — `/meta/object` in `en` and `zh-CN`, the catalog's plural + * included — is pinned in `@objectstack/rest`'s + * `meta-object-extension-plural-label.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from './registry.js'; + +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; +const PARTNER = 'com.partner.crm-ext'; + +const TASK = { + name: 'crm_task', + label: 'Task', + pluralLabel: 'Tasks', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +function registry(): SchemaRegistry { + const r = new SchemaRegistry({ multiTenant: false }); + r.logLevel = 'silent'; + r.registerObject(clone(TASK) as never, BASE, undefined, 'own'); + return r; +} + +const served = (r: SchemaRegistry) => r.getObject('crm_task') as unknown as Record; +const scalars = (doc: Record) => ({ + label: doc.label, + pluralLabel: doc.pluralLabel, + hasPluralLabel: Object.prototype.hasOwnProperty.call(doc, 'pluralLabel'), +}); + +describe('#30391 — an extension that renames only label', () => { + it('is served with the new label and no pluralLabel', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false }); + }); + + it('leaves the packaged base its plural, which the translators compare against', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR); + const base = r.getPackagedObjectBase('crm_task') as unknown as Record; + expect(scalars(base)).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true }); + expect(base._packageId).toBe(BASE); + }); + + it('is idempotent over an already-folded base', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR); + const once = served(r); + const twice = r.foldObjectExtendersOnto('crm_task', clone(once) as never) as unknown as Record; + expect(scalars(twice)).toEqual(scalars(once)); + }); +}); + +describe('#30391 — controls: the plural stays', () => { + it('an extension that sets pluralLabel is served as set', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', pluralLabel: 'Work Items' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true }); + }); + + it('an extension that sets only pluralLabel keeps the label', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Work Items', hasPluralLabel: true }); + }); + + it('an extension that changes neither keeps the base plural', () => { + const r = registry(); + r.registerObjectExtension( + { extend: 'crm_task', fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } } }, + PAIR, + ); + expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true }); + }); + + it('an extension that repeats the label it extends renames nothing', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Task' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true }); + }); + + it('a plural the tenant authored stays under a rename (ADR-0029 D9.2a)', () => { + const r = registry(); + r.registerObject({ ...clone(TASK), pluralLabel: 'To-dos', _provenance: 'org' } as never, BASE, undefined, 'overlay'); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'To-dos', hasPluralLabel: true }); + }); + + it('a label the tenant authored makes the extension\'s rename yield, plural and all', () => { + const r = registry(); + r.registerObject({ ...clone(TASK), label: 'Job', _provenance: 'org' } as never, BASE, undefined, 'overlay'); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Job', pluralLabel: 'Tasks', hasPluralLabel: true }); + }); +}); + +describe('#30391 — two extenders, in priority order', () => { + it('a later rename with no plural takes the earlier extender\'s plural off too', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Job', pluralLabel: 'Jobs', priority: 150 }, PARTNER); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 250 }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false }); + }); + + it('a later plural is served over an earlier rename', () => { + const r = registry(); + r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 150 }, PARTNER); + r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items', priority: 250 }, PAIR); + expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true }); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 9b0443fea23..95314c81d4e 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -763,7 +763,9 @@ function isLeftOutExtension(contributor: ObjectContributor, options: ObjectExten * the tenant's `message` and `active`, never the tenant's condition. * - The three {@link OBJECT_FOLD_SCALAR_KEYS} (`label`, `pluralLabel`, * `description`) are overridden last-writer-wins, subject to the - * `tenantAuthored` yield rule below. + * `tenantAuthored` yield rule below. [#30391] A `label` that renames the + * object without a `pluralLabel` beside it also takes the running + * `pluralLabel` off: the plural named the old label. * - `fieldPatches` [ADR-0146 D10, second rule] is APPLIED, never carried: * after the merges above, each patch writes its `BASE_FIELD_DISPLAY_WHITELIST` * keys onto the named field and its option labels onto the options whose @@ -843,7 +845,17 @@ function mergeObjectDefinitions( // Override scalar props (last writer wins) — [ADR-0029 D9.2a] unless the base has been // authored by the tenant, in which case the extender's packaged default yields. const yields = (key: ObjectFoldScalarKey): boolean => tenantAuthored?.has(key) === true; - if (extension.label !== undefined && !yields('label')) merged.label = extension.label; + if (extension.label !== undefined && !yields('label')) { + // [#30391] `pluralLabel` is the plural OF `label`: an extension that renames + // the object and declares no plural leaves the running plural naming the + // old label, so the object is served as one that declares no + // `pluralLabel`. A plural the tenant authored stays (the D9.2a yield), and + // an extension that repeats the label it extends renames nothing. + if (extension.pluralLabel === undefined && extension.label !== merged.label && !yields('pluralLabel')) { + delete merged.pluralLabel; + } + merged.label = extension.label; + } if (extension.pluralLabel !== undefined && !yields('pluralLabel')) { merged.pluralLabel = extension.pluralLabel; } diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 91c49720146..b873c930702 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -3034,7 +3034,23 @@ export async function pairTranslationBundleOf( } catch { return undefined; } - if (typeof base !== 'string' || base === '') return undefined; + return pairTranslationBundleOfBase(p, base, memo); +} + +/** + * `getPairTranslationBundle(base)` on `protocol`, read once per base through + * `memo` — {@link pairTranslationBundleOf}'s second question, also asked by + * {@link withoutStaleObjectPlural} for the base an object's owner ships in. + * `undefined` on every uncertainty, {@link pairTranslationBundleOf}'s rule. + */ +function pairTranslationBundleOfBase( + protocol: unknown, + base: unknown, + memo?: Map>, +): Promise { + const p: any = protocol; + if (typeof base !== 'string' || base === '') return Promise.resolve(undefined); + if (!p || typeof p.getPairTranslationBundle !== 'function') return Promise.resolve(undefined); let pending = memo?.get(base); if (pending === undefined) { pending = Promise.resolve() @@ -3045,6 +3061,66 @@ export async function pairTranslationBundleOf( return pending; } +/** A non-empty string, or `undefined`. */ +function nonEmptyText(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * [#30391] `translated` — what the object translator answered for the served + * object `input` — without a plural the catalog made up for a renamed object. + * + * An object extension that renames `label` and declares no `pluralLabel` is + * served without one (the fold takes the plural of the old label off, + * `mergeObjectDefinitions` in `@objectstack/objectql`). The translator answers + * a missing `pluralLabel` with the catalog's `objects.NAME.pluralLabel`, and + * that entry is the base package's translation of the plural the extension + * left behind: it names the old label. So where `input` carries no plural, the + * packaged base declares one, and `input`'s `label` diverged from the packaged + * one, the catalog's plural is not served. In its place, the ruled order + * (ruling record 6631250894: a translation in the pair, then the override's + * own text, then the base's translation): the base's pair's own translation of + * the plural when it ships one, read through the same translator over the + * pair's bundle alone; otherwise what `input` carries, which is no plural — + * what an object that declares no `pluralLabel` is served. + * + * An object served WITH a plural (an extension that sets one, a tenant rename + * that kept one) and an object whose label is the packaged one are served + * unchanged, as is every object with no packaged base. Returns `translated` + * itself when nothing changes; the pair is read only when the rule applies. + */ +async function withoutStaleObjectPlural( + protocol: unknown, + input: Record, + translated: Record, + packagedBase: unknown, + translateOptions: Record, + memo?: Map>, +): Promise> { + if (nonEmptyText(input.pluralLabel) !== undefined) return translated; + const packaged = packagedBase && typeof packagedBase === 'object' ? packagedBase as Record : undefined; + if (!packaged || nonEmptyText(packaged.pluralLabel) === undefined) return translated; + const label = nonEmptyText(input.label); + if (label === undefined || label === packaged.label) return translated; + const pairBundle = await pairTranslationBundleOfBase(protocol, packaged._packageId, memo); + let pairPlural: string | undefined; + if (pairBundle !== undefined && typeof input.name === 'string') { + const { translateMetadataDocument } = await import('@objectstack/spec/system'); + const read = translateMetadataDocument('object', { name: input.name } as any, pairBundle as any, translateOptions as any); + pairPlural = nonEmptyText((read as Record | undefined)?.pluralLabel); + } + if (pairPlural !== undefined) { + return translated.pluralLabel === pairPlural ? translated : { ...translated, pluralLabel: pairPlural }; + } + if (!('pluralLabel' in input)) { + if (!('pluralLabel' in translated)) return translated; + const served: Record = { ...translated }; + delete served.pluralLabel; + return served; + } + return translated.pluralLabel === input.pluralLabel ? translated : { ...translated, pluralLabel: input.pluralLabel }; +} + /** How a transport reaches what the list translation reads. */ export interface MetaListTranslationSources { /** This request's i18n service, or `undefined` when the deployment has none. */ @@ -3110,12 +3186,19 @@ export async function translateMetaList( // OUTER `{ type, items }`), so every element translates directly (#5563). const translated = await Promise.all(arr.map(async (item) => { const pairBundle = p === undefined ? undefined : await pairTranslationBundleOf(p, metaType, item, pairBundles); + const packagedBase = packagedObjectBaseOf(p, metaType, item?.name); const out = translateMetadataDocument(metaType, item, bundle, { ...metaTranslateOptions(i18n, locale), - packagedBase: packagedObjectBaseOf(p, metaType, item?.name), + packagedBase, ...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}), }); - return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out; + if (!item || typeof item !== 'object') return out; + if (metaType === 'view') return withoutInventedViewLabel(item, out); + // [#30391] A renamed object's plural, read once per base for the page. + if (metaType === 'object') { + return withoutStaleObjectPlural(p, item, out, packagedBase, metaTranslateOptions(i18n, locale), pairBundles); + } + return out; })); return Array.isArray(raw) ? translated : { ...raw, items: translated }; } @@ -3183,7 +3266,15 @@ export async function translateMetaDocument( packagedBase, ...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}), }); - return metaType === 'view' ? withoutInventedViewLabel(document as Record, translated) : translated; + if (metaType === 'view') return withoutInventedViewLabel(document as Record, translated); + if (metaType === 'object' && translated && typeof translated === 'object') { + // [#30391] A renamed object's plural. + return withoutStaleObjectPlural( + p, document as Record, translated as Record, packagedBase, + metaTranslateOptions(i18n, locale), + ); + } + return translated; } /** diff --git a/packages/rest/src/meta-object-extension-plural-label.test.ts b/packages/rest/src/meta-object-extension-plural-label.test.ts new file mode 100644 index 00000000000..2b352336ed6 --- /dev/null +++ b/packages/rest/src/meta-object-extension-plural-label.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30391 — an object extension that renames `label` and sets no `pluralLabel` + * is served as an object that declares no `pluralLabel`: the base's plural + * names the old label, so neither it nor the catalog's translation of it is + * served for the renamed object. The pair's own translation of the plural, when + * the extension's `.custom` package ships one, is served first (ruling record + * 6631250894: a translation in the pair, then the override's own text, then + * the base's translation). + * + * Real `SchemaRegistry`, real protocol, real `RestServer`, an i18n service + * whose catalog is the deployment's (every package's translations merged, the + * pair's authored row included). Both `/meta/object` reads, `en` and `zh-CN`. + * + * The reference is MEASURED on the same boot, never written down: an object + * that declares no `pluralLabel` (`crm_note`) is read through the same routes, + * and the renamed object's plural must equal what that one serves. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; + +/** The base package's object: both scalars declared. */ +const TASK = { + name: 'crm_task', + label: 'Task', + pluralLabel: 'Tasks', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, +}; + +/** The base package's object that declares no plural: the platform's own answer for a missing one. */ +const NOTE = { + name: 'crm_note', + label: 'Note', + fields: { body: { name: 'body', type: 'text', label: 'Body' } }, +}; + +/** The base package's catalog: `en` repeats the source (what `i18n:extract` writes), `zh-CN` translates it. */ +const BASE_CATALOG: Record = { + en: { objects: { crm_task: { label: 'Task', pluralLabel: 'Tasks' }, crm_note: { label: 'Note' } } }, + 'zh-CN': { objects: { crm_task: { label: '任务', pluralLabel: '任务' }, crm_note: { label: '备注' } } }, +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +type Row = Record; + +/** + * Plain equality and `$in` (the by-name read asks `type` in both spellings); + * any other operator or combinator is refused, so a matcher that ignored one + * proves nothing. + */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${key}`); + if (want !== null && typeof want === 'object') { + const ops = Object.keys(want); + const list = (want as { $in?: unknown }).$in; + if (ops.length !== 1 || !Array.isArray(list)) throw new Error(`fake engine: unsupported operator on ${key}`); + if (!list.includes(row[key] ?? null)) return false; + continue; + } + if ((row[key] ?? null) !== want) return false; + } + return true; +} + +/** A pair's saved `translation` row, as `getPairTranslationBundle` reads it. */ +function pairTranslationRow(locale: string, objects: Record): Row { + const name = `pair_${locale}`; + return { + id: name, type: 'translation', name, package_id: PAIR, organization_id: null, state: 'active', + metadata: JSON.stringify({ name, locale, objects }), + }; +} + +/** Deep-merge `top` over `under`, one locale's data: the deployment catalog's shape. */ +function overlay(under: any, top: any): any { + if (!top) return under; + const out: any = { ...under }; + for (const [k, v] of Object.entries(top)) { + out[k] = v && typeof v === 'object' && under?.[k] && typeof under[k] === 'object' ? overlay(under[k], v) : v; + } + return out; +} + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +interface Boot { + /** The `.custom` extension of `crm_task`, or none. */ + extension?: Record; + /** The pair's own translations, locale → `objects` data; they also reach the deployment catalog. */ + pairObjects?: Record>; +} + +/** Boot one host and answer `read(locale, name)` → the served object on both reads. */ +function boot(opts: Boot = {}) { + const registry = new SchemaRegistry(); + registry.registerObject(clone(TASK) as never, BASE, undefined, 'own'); + registry.registerObject(clone(NOTE) as never, BASE, undefined, 'own'); + if (opts.extension) registry.registerObjectExtension({ extend: 'crm_task', ...clone(opts.extension) }, PAIR); + + const rows: Row[] = Object.entries(opts.pairObjects ?? {}).map(([locale, objects]) => pairTranslationRow(locale, objects)); + const read = async (table: string, q?: { where?: Record; limit?: number }) => { + const found = table === 'sys_metadata' ? rows.filter((r) => matches(r, q?.where)) : []; + return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; + }; + const engine = { + registry, + find: read, + findOne: async (table: string, q: { where: Record }) => { + assertEngineFindOnePredicate(table, q); + return (await read(table, q))[0] ?? null; + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine as never, undefined, 'env_test'); + + // The deployment catalog: every package's translations deep-merged, the pair's authored rows on top. + const catalog = (locale: string) => overlay(BASE_CATALOG[locale], opts.pairObjects?.[locale] && { objects: opts.pairObjects[locale] }); + const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => catalog(locale), + getDefaultLocale: () => 'en', + }; + + const rest = new RestServer( + mockServer() as never, protocol as never, { api: { requireAuth: false } } as never, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + // i18nServiceProvider — the 14th constructor argument. + async () => i18nService as never, + ); + (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + const routes = rest.getRouteManager(); + + const run = async (path: string, params: Record, locale: string) => { + const entry = routes.get('GET', path); + if (!entry) throw new Error(`route not registered: ${path}`); + let body: any; + const res = { + status: () => res, header: () => res, + json: (b: unknown) => { body = b; }, send: (b: unknown) => { body = b; }, + } as unknown as Parameters[1]; + await entry.handler( + { params, query: {}, headers: { 'accept-language': locale }, method: 'GET', path } as unknown as Parameters[0], + res, + ); + return body; + }; + + /** The served object on the by-name read and on the list read, as `{ label, pluralLabel }`. */ + return async (locale: string, name: string) => { + const byName = (await run('/api/v1/meta/:type/:name', { type: 'object', name }, locale))?.item; + const listBody = await run('/api/v1/meta/:type', { type: 'object' }, locale); + const listed = (Array.isArray(listBody) ? listBody : listBody?.items ?? []).find((o: any) => o?.name === name); + const scalars = (doc: any) => ({ + label: doc?.label, + pluralLabel: doc?.pluralLabel, + hasPluralLabel: !!doc && Object.prototype.hasOwnProperty.call(doc, 'pluralLabel'), + }); + return { byName: scalars(byName), listed: scalars(listed) }; + }; +} + +const LOCALES = ['en', 'zh-CN'] as const; + +describe('#30391 — the platform\'s own answer for an object with no pluralLabel (the reference)', () => { + for (const locale of LOCALES) { + it(`an object that declares no pluralLabel is served with none [${locale}]`, async () => { + const read = boot(); + const note = await read(locale, 'crm_note'); + expect(note.byName.hasPluralLabel).toBe(false); + expect(note.listed.hasPluralLabel).toBe(false); + }); + } +}); + +describe('#30391 — an extension that renames only label', () => { + const RENAME = { label: 'Work Item' }; + + for (const locale of LOCALES) { + it(`serves the renamed label and what an object with no pluralLabel serves [${locale}]`, async () => { + const read = boot({ extension: RENAME }); + const reference = await read(locale, 'crm_note'); + const task = await read(locale, 'crm_task'); + for (const served of [task.byName, task.listed]) { + expect(served.label).toBe('Work Item'); + expect(served.hasPluralLabel).toBe(reference.byName.hasPluralLabel); + expect(served.pluralLabel).toBe(reference.byName.pluralLabel); + } + }); + } + + it('serves the pair\'s own translation of the plural first, where the pair ships one', async () => { + const read = boot({ extension: RENAME, pairObjects: { 'zh-CN': { crm_task: { pluralLabel: '工作项' } } } }); + const zh = await read('zh-CN', 'crm_task'); + expect(zh.byName.pluralLabel).toBe('工作项'); + expect(zh.listed.pluralLabel).toBe('工作项'); + // A locale the pair does not translate is served no plural, as before. + const en = await read('en', 'crm_task'); + expect(en.byName.hasPluralLabel).toBe(false); + expect(en.listed.hasPluralLabel).toBe(false); + }); + + it('a deployment translation that is not the pair\'s is not the pair\'s translation', async () => { + // The base's catalog translates its OWN plural: with the pair silent, + // that entry is exactly what must not come back for the renamed object. + const read = boot({ extension: RENAME, pairObjects: { 'zh-CN': { crm_task: { label: '工作项' } } } }); + const zh = await read('zh-CN', 'crm_task'); + expect(zh.byName.hasPluralLabel).toBe(false); + expect(zh.listed.hasPluralLabel).toBe(false); + }); +}); + +describe('#30391 — controls', () => { + it('an extension that sets pluralLabel is served as set', async () => { + const read = boot({ extension: { label: 'Work Item', pluralLabel: 'Work Items' } }); + for (const locale of LOCALES) { + const task = await read(locale, 'crm_task'); + for (const served of [task.byName, task.listed]) { + expect(served.label).toBe('Work Item'); + expect(served.pluralLabel).toBe('Work Items'); + } + } + }); + + it('an extension that sets only pluralLabel keeps the base label and serves its plural', async () => { + const read = boot({ extension: { pluralLabel: 'Work Items' } }); + const zh = await read('zh-CN', 'crm_task'); + expect(zh.byName.label).toBe('任务'); + expect(zh.byName.pluralLabel).toBe('Work Items'); + }); + + it('an extension that changes neither keeps the base plural and its translation', async () => { + const read = boot({ extension: { fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } } } }); + const en = await read('en', 'crm_task'); + const zh = await read('zh-CN', 'crm_task'); + for (const served of [en.byName, en.listed]) expect(served).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true }); + for (const served of [zh.byName, zh.listed]) expect(served).toEqual({ label: '任务', pluralLabel: '任务', hasPluralLabel: true }); + }); + + it('an extension that repeats the base label is no rename', async () => { + const read = boot({ extension: { label: 'Task' } }); + const zh = await read('zh-CN', 'crm_task'); + expect(zh.byName.pluralLabel).toBe('任务'); + expect(zh.listed.pluralLabel).toBe('任务'); + }); + + it('an object nothing extends keeps its plural and its translation', async () => { + const read = boot({ extension: { label: 'Work Item' } }); + const zh = await read('zh-CN', 'crm_note'); + expect(zh.byName.label).toBe('备注'); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 171fb7a76e7..4c491a1ff99 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4581,6 +4581,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/rest/src/meta-object-extension-plural-label.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/meta-object-extension-property-classes.test.ts", "verb": "findOne",