diff --git a/.changeset/view-label-resolve-served-shape.md b/.changeset/view-label-resolve-served-shape.md new file mode 100644 index 0000000000..487ee36e1f --- /dev/null +++ b/.changeset/view-label-resolve-served-shape.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 视图标签 / 描述现在能真正解析出译文(#4854) + +`resolveViewLabel` / `resolveViewDescription` 读取的两个字段,运行时实际下发的 +视图文档一个都没有,因此**任何按正常方式(`defineView`)编写的视图,标签永远 +落回英文字面量**,无论翻译包里写了什么。列表视图切换器横在每个对象列表页顶部, +所以在纯中文部署里,这是屏幕上最显眼的一处残留英文。 + +两处失配互相独立,任何一处都足以让解析失败,现已一并修复: + +1. **对象名取不到。** 旧代码读 `view.objectName ?? view.data?.object`;而 + `GET /api/v1/meta/view?object=…` 下发的文档把对象放在**顶层 `object`**, + 授权配置嵌在 `config` 下。于是 `objectName` 为 `undefined`,函数在 + `if (!bundle || !objectName)` 处就返回了字面量,根本没走到查找。 + 现在按 `objectName → object → data.object → config.data.object` 依次取值, + 与 i18n 提取器(`packages/cli/src/utils/i18n-extract.ts`)判定对象的顺序 + 一致 —— 写 `_views` 键的那一端和读它的这一端,从此对"哪个字段代表对象" + 有相同答案。 +2. **查找键也是错的。** 旧代码用 `view.name` 直接查;而下发文档的 `name` 是 + 注册表分配的全局唯一身份 `.`(如 + `crm_account.account_gallery`),翻译包按**裸键**存放 + (`objects.._views..label`)。现在查找前先剥掉 + `.` 前缀 —— 这是对 `expandViewContainer` 组装规则的**反解**,不是 + 容错别名;没有前缀的名字(手工构造的视图)原样使用,行为不变。 + +**非破坏性。** `ViewLike` 仅新增两个可选字段(`object`、`config`),既有调用 +方式全部照旧;之前能解析的场景没有一个改变结果 —— 在此之前,下发文档这条路径 +上本就没有任何东西能解析成功。应用侧无需改动:`_views` 的键仍然是编写视图时 +用的裸键。 + +已知遗留(不在本次修复范围,另行跟踪):只声明了默认 `list`(没有 `listViews`) +的容器仍解析不出译文 —— 提取器写的键是 `list`,而组装器给它的注册名是 +`.default`。这是两个**生产方**之间的分歧,须在生产端统一,不能靠消费端 +再加一层兼容。 diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 03441f63d4..a57a5c145f 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -15,6 +15,9 @@ import { resolveObjectFieldLabels, toLocaleDescriptors, } from './i18n-resolver'; +// #4854 — the served view document is whatever THIS composer emits, so the +// fixture below is generated by it rather than transcribed from a bug report. +import { expandViewContainer } from '../ui/view.zod'; describe('ObjectTranslationDataSchema (_views/_actions extensions)', () => { it('accepts _views entries', () => { @@ -198,6 +201,131 @@ describe('resolveViewDescription', () => { }); }); +/** + * #4854 — the shape the RUNTIME actually serves. + * + * `GET /api/v1/meta/view?object=account` returns the `ExpandedViewItem`s that + * `expandViewContainer` produces and the ObjectQL engine registers verbatim; + * `RestServer` filters them on `viewKind && object === ` and hands each + * one to `translateMetadataDocument('view', …)`. That document carries the + * object at top-level `object` (NOT `objectName`, NOT `data.object`) and a + * `name` namespaced to `.`, while translations key on the bare + * `` — so both halves of the old lookup missed and every view label + * fell back to its English literal. + * + * The fixture is DERIVED from the composer rather than hand-copied, so it + * cannot drift from the serving path: if `expandViewContainer` ever changes how + * it spells identity, these expectations change with it instead of silently + * describing a shape the runtime no longer produces. + */ +describe('resolveViewLabel — served view-document shape (#4854)', () => { + const container = { + listViews: { + all_accounts: { + label: 'All Accounts', + type: 'grid' as const, + data: { provider: 'object' as const, object: 'account' }, + columns: [{ field: 'name' }], + }, + // Same object, no `_views` entry in the bundle — the negative control. + recent_accounts: { + label: 'Recent Accounts', + type: 'grid' as const, + data: { provider: 'object' as const, object: 'account' }, + columns: [{ field: 'name' }], + }, + }, + // A default form carries no `data` at all, so top-level `object` is the + // ONLY field that can identify its object. + form: { + type: 'simple' as const, + sections: [{ label: 'Details', fields: [{ field: 'name' }] }], + }, + }; + + const served = expandViewContainer('account', container); + const byName = (name: string) => { + const item = served.find((v) => v.name === name); + if (!item) throw new Error(`fixture missing ${name}; got ${served.map((v) => v.name).join(', ')}`); + return item as unknown as Parameters[1]; + }; + + it('composes the identity this test is pinned to', () => { + const item = byName('account.all_accounts') as any; + // The two fields the resolver used to read are absent from a real document. + expect(item.objectName).toBeUndefined(); + expect(item.data).toBeUndefined(); + // The two it must read instead. + expect(item.object).toBe('account'); + expect(item.name).toBe('account.all_accounts'); + // The bare key lives ONLY in the namespaced name — not in `config.name`. + expect(item.config?.name).toBeUndefined(); + }); + + it('resolves a translation from top-level `object` + the bare view key', () => { + expect(resolveViewLabel(bundle, byName('account.all_accounts'), { locale: 'zh-CN' })) + .toBe('全部客户'); + }); + + it('resolves the description the same way', () => { + expect(resolveViewDescription(bundle, byName('account.all_accounts'), { locale: 'zh-CN' })) + .toBe('所有客户'); + }); + + it('falls back through the locale chain to en', () => { + expect( + resolveViewLabel(bundle, byName('account.all_accounts'), { + locale: 'fr-FR', + fallbackChain: ['en'], + }), + ).toBe('All Accounts'); + }); + + it('falls back to the literal label when the object has no matching _views entry', () => { + expect(resolveViewLabel(bundle, byName('account.recent_accounts'), { locale: 'zh-CN' })) + .toBe('Recent Accounts'); + }); + + it('resolves an object whose config carries no `data` (default form)', () => { + const form = served.find((v) => v.viewKind === 'form')!; + expect((form.config as any).data).toBeUndefined(); + // No `_views` entry for it — but it must reach the lookup on top-level + // `object` instead of bailing at the `!objectName` guard, i.e. fall back to + // the literal name rather than to `undefined`. + expect(resolveViewLabel(bundle, form as any, { locale: 'zh-CN' })).toBe(form.name); + }); + + it('translates through the REST boundary entry point', () => { + const out = translateMetadataDocument('view', byName('account.all_accounts'), bundle, { + locale: 'zh-CN', + }); + expect(out.label).toBe('全部客户'); + // Identity and payload survive translation untouched. + expect(out.name).toBe('account.all_accounts'); + expect(out.object).toBe('account'); + expect(out.viewKind).toBe('list'); + expect(out.config).toEqual(byName('account.all_accounts').config); + }); + + it('does not strip a prefix that is not this view\'s object', () => { + // A bare name stays bare (the hand-constructed shape), and a name prefixed + // with a DIFFERENT object is not truncated. + const view = { name: 'other_object.all_accounts', object: 'account', label: 'All Accounts' }; + expect(resolveViewLabel(bundle, view, { locale: 'zh-CN' })).toBe('All Accounts'); + }); + + it('still resolves the retargeting case via config.data.object', () => { + // A named list view may bind another object through its own config data; + // with no top-level `object`, `config.data.object` is the remaining source. + const view = { + name: 'all_accounts', + label: 'All Accounts', + config: { data: { object: 'account' } }, + }; + expect(resolveViewLabel(bundle, view, { locale: 'zh-CN' })).toBe('全部客户'); + }); +}); + describe('resolveActionLabel + confirm + success', () => { it('translates an object-bound action', () => { const action = { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 3414b8e1ab..6425f2a93f 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -9,12 +9,17 @@ * (`*.view.ts`, `*.actions.ts`); these helpers translate at render time using * the standardized keys: * - * objects.._views..label - * objects.._views..description + * objects.._views..label + * objects.._views..description * objects.._actions..label * objects.._actions..confirmText * objects.._actions..successMessage * + * `` is the BARE authoring key (`listViews.`, or the default + * list/form key) — never the `.` identity the registry assigns a + * served view document. `resolveViewLabel` derives the bare key from that + * identity, so the same bundle serves both hand-built and served views (#4854). + * * For object-less actions (no `objectName`), helpers fall back to: * * globalActions..label / .confirmText / .successMessage @@ -27,15 +32,47 @@ import type { TranslationBundle, TranslationData } from './translation.zod'; -/** Minimal view shape consumed by `resolveViewLabel`. */ +/** + * Minimal view shape consumed by `resolveViewLabel`. + * + * Covers BOTH shapes that reach the resolver (#4854): + * + * 1. a hand-constructed view (`{ name, label, objectName }`) — the shape + * callers assemble when they already know the object; + * 2. the **served view document**, which is what + * `GET /api/v1/meta/view?object=…` actually returns. That document is the + * `ExpandedViewItem` produced by `expandViewContainer` + * (`ui/view.zod.ts`) and registered verbatim by the ObjectQL engine + * (`engine.ts` boot loop) — it binds its object at top-level `object` and + * namespaces `name` to `.`. + */ export interface ViewLike { + /** + * View identity. The served document namespaces this to `.` + * (`crm_account.account_gallery`); translations key on the bare ``, + * so the object prefix is stripped before lookup — see + * {@link viewTranslationKey}. + */ name: string; label?: string; description?: string; /** Object the view is bound to. Required for translation lookup. */ objectName?: string; + /** + * The bound object as the SERVED document spells it. `expandViewContainer` + * stamps `object` (never `objectName`) onto every expanded ViewItem, so this + * — not `objectName` — is the field a real `/meta/view` response carries. + */ + object?: string; /** Some view definitions name the bound object via `data.object`. */ data?: { object?: string }; + /** + * Served ViewItems nest the authored view config here, which carries its own + * `data.object` for views that retarget another object. Read only as a last + * resort — a default `form` config carries no `data` at all, which is why + * top-level `object` is the load-bearing field. + */ + config?: { data?: { object?: string } }; } /** Minimal action shape consumed by the action resolvers. */ @@ -138,8 +175,43 @@ function localeChain(opts?: ResolveOptions): string[] { return chain; } +/** + * The object a view binds to, across every shape that reaches this resolver. + * + * `objectName` / `data.object` alone could never match a served document + * (#4854): the view-document composer `expandViewContainer` emits `object` at + * the top level and leaves the authored config — including its `data` — nested + * under `config`, so the lookup bailed at the `!objectName` guard for every + * view authored through `defineView`. The order here mirrors the i18n + * extractor's own `viewObjectName` (`packages/cli/src/utils/i18n-extract.ts`), + * so the surface that WRITES `_views` keys and the resolver that READS them + * agree on which field identifies the object. + */ function viewObjectName(view: ViewLike): string | undefined { - return view.objectName ?? view.data?.object; + return view.objectName ?? view.object ?? view.data?.object ?? view.config?.data?.object; +} + +/** + * The `_views` key a view's translations live under: the **bare** view key. + * + * Translation bundles key on the authoring key (`objects.._views.` + * — what `pushViewEntries` in the i18n extractor writes, and what every shipped + * bundle carries), while the served document namespaces `name` to + * `.` because that is the registry's globally-unique identity + * (`ViewItemNameSchema`). Stripping the object prefix decodes that composition; + * it is not an alias. A name without the prefix — a hand-constructed view — is + * already bare and passes through untouched. + * + * Deliberately does NOT consult `config.name`. The bare key is the *container + * key* (`listViews.`), which `expandViewContainer` puts in the name and + * nowhere else; `config.name` is an optional, author-supplied field that is + * absent from every view in the repo's own apps and, when present on a + * colliding view, names a DIFFERENT view than the one being resolved. One key, + * derived from the identity the registry actually assigned. + */ +function viewTranslationKey(view: ViewLike, objectName: string): string { + const prefix = `${objectName}.`; + return view.name.startsWith(prefix) ? view.name.slice(prefix.length) : view.name; } /** @@ -154,9 +226,10 @@ export function resolveViewLabel( const fallback = view.label ?? view.name; const objectName = viewObjectName(view); if (!bundle || !objectName) return fallback; + const key = viewTranslationKey(view, objectName); for (const code of localeChain(opts)) { const data = pickData(bundle, code); - const candidate = data?.objects?.[objectName]?._views?.[view.name]?.label; + const candidate = data?.objects?.[objectName]?._views?.[key]?.label; if (typeof candidate === 'string' && candidate.length > 0) return candidate; } return fallback; @@ -173,10 +246,11 @@ export function resolveViewDescription( ): string | undefined { const objectName = viewObjectName(view); if (bundle && objectName) { + const key = viewTranslationKey(view, objectName); for (const code of localeChain(opts)) { const data = pickData(bundle, code); const candidate = - data?.objects?.[objectName]?._views?.[view.name]?.description; + data?.objects?.[objectName]?._views?.[key]?.description; if (typeof candidate === 'string' && candidate.length > 0) return candidate; } }