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
37 changes: 37 additions & 0 deletions .changeset/view-label-resolve-served-shape.md
Original file line number Diff line number Diff line change
@@ -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` 是
注册表分配的全局唯一身份 `<object>.<viewKey>`(如
`crm_account.account_gallery`),翻译包按**裸键**存放
(`objects.<object>._views.<viewKey>.label`)。现在查找前先剥掉
`<object>.` 前缀 —— 这是对 `expandViewContainer` 组装规则的**反解**,不是
容错别名;没有前缀的名字(手工构造的视图)原样使用,行为不变。

**非破坏性。** `ViewLike` 仅新增两个可选字段(`object`、`config`),既有调用
方式全部照旧;之前能解析的场景没有一个改变结果 —— 在此之前,下发文档这条路径
上本就没有任何东西能解析成功。应用侧无需改动:`_views` 的键仍然是编写视图时
用的裸键。

已知遗留(不在本次修复范围,另行跟踪):只声明了默认 `list`(没有 `listViews`)
的容器仍解析不出译文 —— 提取器写的键是 `list`,而组装器给它的注册名是
`<object>.default`。这是两个**生产方**之间的分歧,须在生产端统一,不能靠消费端
再加一层兼容。
128 changes: 128 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 === <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 `<object>.<viewKey>`, while translations key on the bare
* `<viewKey>` — 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<typeof resolveViewLabel>[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 = {
Expand Down
86 changes: 80 additions & 6 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@
* (`*.view.ts`, `*.actions.ts`); these helpers translate at render time using
* the standardized keys:
*
* objects.<object>._views.<view_name>.label
* objects.<object>._views.<view_name>.description
* objects.<object>._views.<view_key>.label
* objects.<object>._views.<view_key>.description
* objects.<object>._actions.<action_name>.label
* objects.<object>._actions.<action_name>.confirmText
* objects.<object>._actions.<action_name>.successMessage
*
* `<view_key>` is the BARE authoring key (`listViews.<key>`, or the default
* list/form key) — never the `<object>.<key>` 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.<action_name>.label / .confirmText / .successMessage
Expand All @@ -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 `<object>.<viewKey>`.
*/
export interface ViewLike {
/**
* View identity. The served document namespaces this to `<object>.<viewKey>`
* (`crm_account.account_gallery`); translations key on the bare `<viewKey>`,
* 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. */
Expand Down Expand Up @@ -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.<object>._views.<key>`
* — what `pushViewEntries` in the i18n extractor writes, and what every shipped
* bundle carries), while the served document namespaces `name` to
* `<object>.<key>` 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.<key>`), 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;
}

/**
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down
Loading