diff --git a/.changeset/view-translation-keys-bare-only-3502.md b/.changeset/view-translation-keys-bare-only-3502.md new file mode 100644 index 0000000000..5cce40db9a --- /dev/null +++ b/.changeset/view-translation-keys-bare-only-3502.md @@ -0,0 +1,15 @@ +--- +"@object-ui/i18n": patch +--- + +Resolve `_views` translation keys by the bare view name only — the prefixed full name is no longer a second candidate + +`useObjectLabel().viewLabel` / `viewDescription` / `viewEmptyState` build their key by stripping the object prefix off the runtime view id (`crm_opportunity.pipeline_kanban` → `objects.crm_opportunity._views.pipeline_kanban.`). Until now, if that bare key missed, the resolver **also** tried the prefixed full name — `objects.crm_opportunity._views.crm_opportunity.pipeline_kanban.` — so a bundle authored against the prefixed spelling resolved too. + +**Behavior change:** it no longer does. A `_views` entry keyed by the prefixed full name is not read at all; the label falls back to the metadata default, exactly as it would if no translation had been written. Bundles keyed by the bare view name — the only spelling the extractor emits and `os lint` accepts — are unaffected. + +This closes an asymmetry, not a feature. The server-side resolver reads the one bare key (objectstack#5165), so a prefixed-key bundle produced a **translated label in the Console and English everywhere else**: the REST boundary, mobile, plain HTTP and SDUI consumers do not run this second resolution pass. The half-success was harder to notice than a clean miss, and it fossilized a second de-facto spelling of a key the platform has now converged on: per the objectstack#5164 ruling (2026-08-06, option A), the canonical `_views` key is the runtime view identity's bare name, with the i18n extractor deriving it from the view composer (objectstack#6124) and `packages/lint` enforcing that single spelling (objectstack#6038). This is the third and last leg of that convergence. + +The object-name axis is untouched: a bundle written against the short object name (`objects.opportunity._views.…`) still resolves when the runtime presents the namespaced name (`crm__opportunity`). + +**If a label stopped translating after this upgrade,** its `_views` key is written with the object prefix. Drop the prefix — `_views.crm_opportunity.pipeline_kanban.label` becomes `_views.pipeline_kanban.label`. `os lint` names these for you: a prefixed key is reported as `translation-target-unknown`, because no view of the object declares it. diff --git a/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx index 49df97b83e..206296d7fb 100644 --- a/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx +++ b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { renderHook } from '@testing-library/react'; import React from 'react'; import { I18nProvider, useObjectTranslation } from '../provider'; @@ -11,6 +11,16 @@ const wrapper = ({ children }: { children: React.ReactNode }) => children, ); +/** Same provider, with the dev missing-key warner explicitly on. */ +const warningWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + I18nProvider, + { + config: { defaultLanguage: 'en', detectBrowserLanguage: false, warnMissingKeys: true }, + }, + children, + ); + describe('useObjectLabel().viewLabel', () => { it('resolves an authored view translation from a qualified runtime view id', () => { const { result } = renderHook( @@ -100,3 +110,203 @@ describe('useObjectLabel().viewLabel', () => { ).toBe('Localized pipeline'); }); }); + +/** + * objectstack#5164 ruling A (2026-08-06): the canonical `_views` translation key + * is the runtime view identity's BARE name. The extractor now derives it from the + * view composer (objectstack#6124) and `packages/lint` enforces that one spelling + * (objectstack#6038); this resolver used to additionally accept the prefixed full + * name (`_views..`) as a second candidate, which made the + * Console show a translated label while the server-side resolver — which reads the + * one key only (objectstack#5165) — still served English to every consumer that + * does not re-resolve (REST, mobile, plain HTTP, SDUI). + * + * These pin BOTH directions of the narrowing: the bare key resolves, and the + * prefixed spelling falls through to the metadata default on every surface that + * goes through `viewSuffixes` (label / description / emptyState). + */ +describe('useObjectLabel() view keys — bare-key-only resolution (objectui#3502)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not resolve a view translation authored under the prefixed full name', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + // The rejected spelling: the view name still carries its object + // prefix, so the bundle nests `crm_opportunity` under `_views`. + crm_opportunity: { + pipeline_kanban: { + label: 'Prefixed pipeline', + description: 'Prefixed pipeline description', + emptyState: { + title: 'Prefixed empty title', + message: 'Prefixed empty message.', + }, + }, + }, + }, + }, + }, + }, + }, + true, + true, + ); + + expect( + result.current.labels.viewLabel( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Sales Pipeline'); + expect( + result.current.labels.viewDescription( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Manage opportunities by stage', + ), + ).toBe('Manage opportunities by stage'); + expect( + result.current.labels.viewEmptyState( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + { title: 'No opportunities', message: 'Create one to begin.' }, + ), + ).toEqual({ + title: 'No opportunities', + message: 'Create one to begin.', + }); + }); + + it('resolves the bare key even when a prefixed sibling exists', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + pipeline_kanban: { label: 'Bare pipeline' }, + crm_opportunity: { + pipeline_kanban: { label: 'Prefixed pipeline' }, + }, + }, + }, + }, + }, + }, + true, + true, + ); + + expect( + result.current.labels.viewLabel( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Bare pipeline'); + }); + + it('keeps the object-name axis: a short-object-name bundle still resolves', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + // Only the view axis was narrowed. The object axis (namespaced name first, + // then the `__`-stripped base name) is a separate fallback and stays. + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + opportunity: { + _views: { + pipeline_kanban: { label: 'Localized pipeline' }, + }, + }, + }, + }, + }, + true, + true, + ); + + expect( + result.current.labels.viewLabel( + 'crm__opportunity', + 'crm__opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Localized pipeline'); + // A runtime id qualified with the short object name strips just as well. + expect( + result.current.labels.viewLabel( + 'crm__opportunity', + 'opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Localized pipeline'); + }); + + it('falls back visibly on screen, without adding dev-console noise', () => { + // "Loud" here means the on-screen label is the untranslated metadata default + // on EVERY consumer, not a Console-only success. The dev missing-key warner + // stays out of it by design: convention probes carry `I18N_PROBE_FLAG` + // (see `i18n.ts`) because they miss on every app that authored no view + // translations at all, so warning here would fire on the healthy path rather + // than the broken one. A `_views` key written under the rejected spelling is + // reported at authoring time by `os lint` (`translation-target-unknown`, + // objectstack#6038) — at the producer, per contract-first. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper: warningWrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + crm_opportunity: { pipeline_kanban: { label: 'Prefixed pipeline' } }, + }, + }, + }, + }, + }, + true, + true, + ); + warnSpy.mockClear(); + + expect( + result.current.labels.viewLabel( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Sales Pipeline'); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index 276293bc4c..3657f94af2 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -205,19 +205,36 @@ export function useObjectLabel() { /** * Build suffix candidates for a list-view scoped key. * - * The runtime uses qualified view ids (`.`) in URLs - * and metadata records, while translation bundles are keyed by the authored - * view name under `_views`. Resolve the unqualified name first so every - * surface can pass its canonical id without leaking the metadata fallback. + * The runtime uses qualified view ids (`.`) in URLs and + * metadata records, while translation bundles are keyed by the **bare** view + * name under `_views` — the runtime view identity's own name, stripped of the + * object prefix. That single spelling is canonical per the objectstack#5164 + * ruling A (2026-08-06): the i18n extractor asks the view composer for the key + * (objectstack#6124) and `packages/lint` enforces exactly that spelling + * (objectstack#6038), so this resolver accepts exactly what those produce. + * + * Deliberately NOT a second candidate: the prefixed full name + * (`_views..`). Accepting it made this client more + * lenient than the server-side resolver, which only ever reads the one key + * (objectstack#5165) — a bundle authored against the prefixed spelling showed + * translated labels in the Console while every consumer that does not run a + * second resolution pass (REST boundary, mobile, plain HTTP, SDUI) still got + * English. A prefixed key now simply misses, and the label falls back to the + * metadata default on every surface alike, so the authoring mistake is visible + * instead of half-hidden. It is caught at authoring time by `os lint`'s + * `translation-target-unknown`, not papered over here. + * + * The object-name candidates (`objects.` then `objects.`) are a + * separate axis and stay: they let bundles written against short object names + * resolve when the runtime presents fully-qualified ones. */ const viewSuffixes = (objectName: string, viewName: string, tail: string): string[] => { const objectNames = [objectName, stripNamespace(objectName)]; const matchedPrefix = objectNames .map((name) => `${name}.`) .find((prefix) => viewName.startsWith(prefix)); - const shortViewName = matchedPrefix ? viewName.slice(matchedPrefix.length) : viewName; - const viewNames = shortViewName === viewName ? [viewName] : [shortViewName, viewName]; - return viewNames.flatMap((name) => objectSuffixes(objectName, `_views.${name}.${tail}`)); + const bareViewName = matchedPrefix ? viewName.slice(matchedPrefix.length) : viewName; + return objectSuffixes(objectName, `_views.${bareViewName}.${tail}`); }; return { @@ -356,7 +373,10 @@ export function useObjectLabel() { /** * Resolve translated list-view label. - * Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`. + * Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`, + * where `{viewName}` is the **bare** view name — pass either the bare name or + * the qualified runtime id, the object prefix is stripped either way. A key + * that spells the prefix out does not resolve (see `viewSuffixes`). */ viewLabel: (objectName: string, viewName: string, fallback: string) => resolve(viewSuffixes(objectName, viewName, 'label'), fallback),