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
15 changes: 15 additions & 0 deletions .changeset/view-translation-keys-bare-only-3502.md
Original file line number Diff line number Diff line change
@@ -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.<tail>`). Until now, if that bare key missed, the resolver **also** tried the prefixed full name — `objects.crm_opportunity._views.crm_opportunity.pipeline_kanban.<tail>` — 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.
212 changes: 211 additions & 1 deletion packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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.<objectName>.<viewName>`) 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();
});
});
36 changes: 28 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,36 @@ export function useObjectLabel() {
/**
* Build suffix candidates for a list-view scoped key.
*
* The runtime uses qualified view ids (`<objectName>.<viewName>`) 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 (`<objectName>.<viewName>`) 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.<objectName>.<viewName>`). 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.<ns__obj>` then `objects.<obj>`) 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 {
Expand Down Expand Up @@ -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),
Expand Down
Loading