Skip to content

Commit 4a56dbd

Browse files
fix(spec): resolve view labels against the served view-document shape (#4854) (#5165)
`resolveViewLabel` / `resolveViewDescription` read two fields the runtime's view document does not carry, so every view authored through `defineView` fell back to its English literal in every locale. Measured against the composer, not the bug report: `expandViewContainer` (`ui/view.zod.ts`) emits `{ name: '<object>.<key>', object, viewKind, label, config, order, scope }`, the ObjectQL engine registers that verbatim, and `RestServer` hands it to `translateMetadataDocument` unchanged. Two independent mismatches, either one sufficient: - the object is at top-level `object` (nested config data under `config`), not `objectName` / `data.object`, so the lookup bailed at the `!objectName` guard before consulting the bundle; - the lookup key was the namespaced registry identity `<object>.<key>`, while bundles key on the bare authoring key. `viewObjectName` now reads `objectName -> object -> data.object -> config.data.object`, matching the i18n extractor's own resolution order, and the lookup derives the bare key by stripping the `<object>.` prefix — a decode of the composer's rule, not a tolerant alias. `config.name` is deliberately not consulted: it is absent from every real view and, on a renamed colliding view, names a different view than the one being resolved. `ViewLike` gains two optional fields only; a bare `name` still resolves unchanged. Tests derive their fixture FROM `expandViewContainer` so they cannot drift from the serving path, and pin that the two old fields are absent from a real document. The four behavioural cases fail against the unfixed resolver. Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9737e0c commit 4a56dbd

3 files changed

Lines changed: 245 additions & 6 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 视图标签 / 描述现在能真正解析出译文(#4854)
6+
7+
`resolveViewLabel` / `resolveViewDescription` 读取的两个字段,运行时实际下发的
8+
视图文档一个都没有,因此**任何按正常方式(`defineView`)编写的视图,标签永远
9+
落回英文字面量**,无论翻译包里写了什么。列表视图切换器横在每个对象列表页顶部,
10+
所以在纯中文部署里,这是屏幕上最显眼的一处残留英文。
11+
12+
两处失配互相独立,任何一处都足以让解析失败,现已一并修复:
13+
14+
1. **对象名取不到。** 旧代码读 `view.objectName ?? view.data?.object`;而
15+
`GET /api/v1/meta/view?object=…` 下发的文档把对象放在**顶层 `object`**,
16+
授权配置嵌在 `config` 下。于是 `objectName` 为 `undefined`,函数在
17+
`if (!bundle || !objectName)` 处就返回了字面量,根本没走到查找。
18+
现在按 `objectName → object → data.object → config.data.object` 依次取值,
19+
与 i18n 提取器(`packages/cli/src/utils/i18n-extract.ts`)判定对象的顺序
20+
一致 —— 写 `_views` 键的那一端和读它的这一端,从此对"哪个字段代表对象"
21+
有相同答案。
22+
2. **查找键也是错的。** 旧代码用 `view.name` 直接查;而下发文档的 `name` 是
23+
注册表分配的全局唯一身份 `<object>.<viewKey>`(如
24+
`crm_account.account_gallery`),翻译包按**裸键**存放
25+
(`objects.<object>._views.<viewKey>.label`)。现在查找前先剥掉
26+
`<object>.` 前缀 —— 这是对 `expandViewContainer` 组装规则的**反解**,不是
27+
容错别名;没有前缀的名字(手工构造的视图)原样使用,行为不变。
28+
29+
**非破坏性。** `ViewLike` 仅新增两个可选字段(`object`、`config`),既有调用
30+
方式全部照旧;之前能解析的场景没有一个改变结果 —— 在此之前,下发文档这条路径
31+
上本就没有任何东西能解析成功。应用侧无需改动:`_views` 的键仍然是编写视图时
32+
用的裸键。
33+
34+
已知遗留(不在本次修复范围,另行跟踪):只声明了默认 `list`(没有 `listViews`)
35+
的容器仍解析不出译文 —— 提取器写的键是 `list`,而组装器给它的注册名是
36+
`<object>.default`。这是两个**生产方**之间的分歧,须在生产端统一,不能靠消费端
37+
再加一层兼容。

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
resolveObjectFieldLabels,
1616
toLocaleDescriptors,
1717
} from './i18n-resolver';
18+
// #4854 — the served view document is whatever THIS composer emits, so the
19+
// fixture below is generated by it rather than transcribed from a bug report.
20+
import { expandViewContainer } from '../ui/view.zod';
1821

1922
describe('ObjectTranslationDataSchema (_views/_actions extensions)', () => {
2023
it('accepts _views entries', () => {
@@ -198,6 +201,131 @@ describe('resolveViewDescription', () => {
198201
});
199202
});
200203

204+
/**
205+
* #4854 — the shape the RUNTIME actually serves.
206+
*
207+
* `GET /api/v1/meta/view?object=account` returns the `ExpandedViewItem`s that
208+
* `expandViewContainer` produces and the ObjectQL engine registers verbatim;
209+
* `RestServer` filters them on `viewKind && object === <object>` and hands each
210+
* one to `translateMetadataDocument('view', …)`. That document carries the
211+
* object at top-level `object` (NOT `objectName`, NOT `data.object`) and a
212+
* `name` namespaced to `<object>.<viewKey>`, while translations key on the bare
213+
* `<viewKey>` — so both halves of the old lookup missed and every view label
214+
* fell back to its English literal.
215+
*
216+
* The fixture is DERIVED from the composer rather than hand-copied, so it
217+
* cannot drift from the serving path: if `expandViewContainer` ever changes how
218+
* it spells identity, these expectations change with it instead of silently
219+
* describing a shape the runtime no longer produces.
220+
*/
221+
describe('resolveViewLabel — served view-document shape (#4854)', () => {
222+
const container = {
223+
listViews: {
224+
all_accounts: {
225+
label: 'All Accounts',
226+
type: 'grid' as const,
227+
data: { provider: 'object' as const, object: 'account' },
228+
columns: [{ field: 'name' }],
229+
},
230+
// Same object, no `_views` entry in the bundle — the negative control.
231+
recent_accounts: {
232+
label: 'Recent Accounts',
233+
type: 'grid' as const,
234+
data: { provider: 'object' as const, object: 'account' },
235+
columns: [{ field: 'name' }],
236+
},
237+
},
238+
// A default form carries no `data` at all, so top-level `object` is the
239+
// ONLY field that can identify its object.
240+
form: {
241+
type: 'simple' as const,
242+
sections: [{ label: 'Details', fields: [{ field: 'name' }] }],
243+
},
244+
};
245+
246+
const served = expandViewContainer('account', container);
247+
const byName = (name: string) => {
248+
const item = served.find((v) => v.name === name);
249+
if (!item) throw new Error(`fixture missing ${name}; got ${served.map((v) => v.name).join(', ')}`);
250+
return item as unknown as Parameters<typeof resolveViewLabel>[1];
251+
};
252+
253+
it('composes the identity this test is pinned to', () => {
254+
const item = byName('account.all_accounts') as any;
255+
// The two fields the resolver used to read are absent from a real document.
256+
expect(item.objectName).toBeUndefined();
257+
expect(item.data).toBeUndefined();
258+
// The two it must read instead.
259+
expect(item.object).toBe('account');
260+
expect(item.name).toBe('account.all_accounts');
261+
// The bare key lives ONLY in the namespaced name — not in `config.name`.
262+
expect(item.config?.name).toBeUndefined();
263+
});
264+
265+
it('resolves a translation from top-level `object` + the bare view key', () => {
266+
expect(resolveViewLabel(bundle, byName('account.all_accounts'), { locale: 'zh-CN' }))
267+
.toBe('全部客户');
268+
});
269+
270+
it('resolves the description the same way', () => {
271+
expect(resolveViewDescription(bundle, byName('account.all_accounts'), { locale: 'zh-CN' }))
272+
.toBe('所有客户');
273+
});
274+
275+
it('falls back through the locale chain to en', () => {
276+
expect(
277+
resolveViewLabel(bundle, byName('account.all_accounts'), {
278+
locale: 'fr-FR',
279+
fallbackChain: ['en'],
280+
}),
281+
).toBe('All Accounts');
282+
});
283+
284+
it('falls back to the literal label when the object has no matching _views entry', () => {
285+
expect(resolveViewLabel(bundle, byName('account.recent_accounts'), { locale: 'zh-CN' }))
286+
.toBe('Recent Accounts');
287+
});
288+
289+
it('resolves an object whose config carries no `data` (default form)', () => {
290+
const form = served.find((v) => v.viewKind === 'form')!;
291+
expect((form.config as any).data).toBeUndefined();
292+
// No `_views` entry for it — but it must reach the lookup on top-level
293+
// `object` instead of bailing at the `!objectName` guard, i.e. fall back to
294+
// the literal name rather than to `undefined`.
295+
expect(resolveViewLabel(bundle, form as any, { locale: 'zh-CN' })).toBe(form.name);
296+
});
297+
298+
it('translates through the REST boundary entry point', () => {
299+
const out = translateMetadataDocument('view', byName('account.all_accounts'), bundle, {
300+
locale: 'zh-CN',
301+
});
302+
expect(out.label).toBe('全部客户');
303+
// Identity and payload survive translation untouched.
304+
expect(out.name).toBe('account.all_accounts');
305+
expect(out.object).toBe('account');
306+
expect(out.viewKind).toBe('list');
307+
expect(out.config).toEqual(byName('account.all_accounts').config);
308+
});
309+
310+
it('does not strip a prefix that is not this view\'s object', () => {
311+
// A bare name stays bare (the hand-constructed shape), and a name prefixed
312+
// with a DIFFERENT object is not truncated.
313+
const view = { name: 'other_object.all_accounts', object: 'account', label: 'All Accounts' };
314+
expect(resolveViewLabel(bundle, view, { locale: 'zh-CN' })).toBe('All Accounts');
315+
});
316+
317+
it('still resolves the retargeting case via config.data.object', () => {
318+
// A named list view may bind another object through its own config data;
319+
// with no top-level `object`, `config.data.object` is the remaining source.
320+
const view = {
321+
name: 'all_accounts',
322+
label: 'All Accounts',
323+
config: { data: { object: 'account' } },
324+
};
325+
expect(resolveViewLabel(bundle, view, { locale: 'zh-CN' })).toBe('全部客户');
326+
});
327+
});
328+
201329
describe('resolveActionLabel + confirm + success', () => {
202330
it('translates an object-bound action', () => {
203331
const action = {

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

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
* (`*.view.ts`, `*.actions.ts`); these helpers translate at render time using
1010
* the standardized keys:
1111
*
12-
* objects.<object>._views.<view_name>.label
13-
* objects.<object>._views.<view_name>.description
12+
* objects.<object>._views.<view_key>.label
13+
* objects.<object>._views.<view_key>.description
1414
* objects.<object>._actions.<action_name>.label
1515
* objects.<object>._actions.<action_name>.confirmText
1616
* objects.<object>._actions.<action_name>.successMessage
1717
*
18+
* `<view_key>` is the BARE authoring key (`listViews.<key>`, or the default
19+
* list/form key) — never the `<object>.<key>` identity the registry assigns a
20+
* served view document. `resolveViewLabel` derives the bare key from that
21+
* identity, so the same bundle serves both hand-built and served views (#4854).
22+
*
1823
* For object-less actions (no `objectName`), helpers fall back to:
1924
*
2025
* globalActions.<action_name>.label / .confirmText / .successMessage
@@ -27,15 +32,47 @@
2732

2833
import type { TranslationBundle, TranslationData } from './translation.zod';
2934

30-
/** Minimal view shape consumed by `resolveViewLabel`. */
35+
/**
36+
* Minimal view shape consumed by `resolveViewLabel`.
37+
*
38+
* Covers BOTH shapes that reach the resolver (#4854):
39+
*
40+
* 1. a hand-constructed view (`{ name, label, objectName }`) — the shape
41+
* callers assemble when they already know the object;
42+
* 2. the **served view document**, which is what
43+
* `GET /api/v1/meta/view?object=…` actually returns. That document is the
44+
* `ExpandedViewItem` produced by `expandViewContainer`
45+
* (`ui/view.zod.ts`) and registered verbatim by the ObjectQL engine
46+
* (`engine.ts` boot loop) — it binds its object at top-level `object` and
47+
* namespaces `name` to `<object>.<viewKey>`.
48+
*/
3149
export interface ViewLike {
50+
/**
51+
* View identity. The served document namespaces this to `<object>.<viewKey>`
52+
* (`crm_account.account_gallery`); translations key on the bare `<viewKey>`,
53+
* so the object prefix is stripped before lookup — see
54+
* {@link viewTranslationKey}.
55+
*/
3256
name: string;
3357
label?: string;
3458
description?: string;
3559
/** Object the view is bound to. Required for translation lookup. */
3660
objectName?: string;
61+
/**
62+
* The bound object as the SERVED document spells it. `expandViewContainer`
63+
* stamps `object` (never `objectName`) onto every expanded ViewItem, so this
64+
* — not `objectName` — is the field a real `/meta/view` response carries.
65+
*/
66+
object?: string;
3767
/** Some view definitions name the bound object via `data.object`. */
3868
data?: { object?: string };
69+
/**
70+
* Served ViewItems nest the authored view config here, which carries its own
71+
* `data.object` for views that retarget another object. Read only as a last
72+
* resort — a default `form` config carries no `data` at all, which is why
73+
* top-level `object` is the load-bearing field.
74+
*/
75+
config?: { data?: { object?: string } };
3976
}
4077

4178
/** Minimal action shape consumed by the action resolvers. */
@@ -138,8 +175,43 @@ function localeChain(opts?: ResolveOptions): string[] {
138175
return chain;
139176
}
140177

178+
/**
179+
* The object a view binds to, across every shape that reaches this resolver.
180+
*
181+
* `objectName` / `data.object` alone could never match a served document
182+
* (#4854): the view-document composer `expandViewContainer` emits `object` at
183+
* the top level and leaves the authored config — including its `data` — nested
184+
* under `config`, so the lookup bailed at the `!objectName` guard for every
185+
* view authored through `defineView`. The order here mirrors the i18n
186+
* extractor's own `viewObjectName` (`packages/cli/src/utils/i18n-extract.ts`),
187+
* so the surface that WRITES `_views` keys and the resolver that READS them
188+
* agree on which field identifies the object.
189+
*/
141190
function viewObjectName(view: ViewLike): string | undefined {
142-
return view.objectName ?? view.data?.object;
191+
return view.objectName ?? view.object ?? view.data?.object ?? view.config?.data?.object;
192+
}
193+
194+
/**
195+
* The `_views` key a view's translations live under: the **bare** view key.
196+
*
197+
* Translation bundles key on the authoring key (`objects.<object>._views.<key>`
198+
* — what `pushViewEntries` in the i18n extractor writes, and what every shipped
199+
* bundle carries), while the served document namespaces `name` to
200+
* `<object>.<key>` because that is the registry's globally-unique identity
201+
* (`ViewItemNameSchema`). Stripping the object prefix decodes that composition;
202+
* it is not an alias. A name without the prefix — a hand-constructed view — is
203+
* already bare and passes through untouched.
204+
*
205+
* Deliberately does NOT consult `config.name`. The bare key is the *container
206+
* key* (`listViews.<key>`), which `expandViewContainer` puts in the name and
207+
* nowhere else; `config.name` is an optional, author-supplied field that is
208+
* absent from every view in the repo's own apps and, when present on a
209+
* colliding view, names a DIFFERENT view than the one being resolved. One key,
210+
* derived from the identity the registry actually assigned.
211+
*/
212+
function viewTranslationKey(view: ViewLike, objectName: string): string {
213+
const prefix = `${objectName}.`;
214+
return view.name.startsWith(prefix) ? view.name.slice(prefix.length) : view.name;
143215
}
144216

145217
/**
@@ -154,9 +226,10 @@ export function resolveViewLabel(
154226
const fallback = view.label ?? view.name;
155227
const objectName = viewObjectName(view);
156228
if (!bundle || !objectName) return fallback;
229+
const key = viewTranslationKey(view, objectName);
157230
for (const code of localeChain(opts)) {
158231
const data = pickData(bundle, code);
159-
const candidate = data?.objects?.[objectName]?._views?.[view.name]?.label;
232+
const candidate = data?.objects?.[objectName]?._views?.[key]?.label;
160233
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
161234
}
162235
return fallback;
@@ -173,10 +246,11 @@ export function resolveViewDescription(
173246
): string | undefined {
174247
const objectName = viewObjectName(view);
175248
if (bundle && objectName) {
249+
const key = viewTranslationKey(view, objectName);
176250
for (const code of localeChain(opts)) {
177251
const data = pickData(bundle, code);
178252
const candidate =
179-
data?.objects?.[objectName]?._views?.[view.name]?.description;
253+
data?.objects?.[objectName]?._views?.[key]?.description;
180254
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
181255
}
182256
}

0 commit comments

Comments
 (0)