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/i18n-view-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
"@objectstack/spec": patch
---

fix(cli,spec): i18n coverage actually gates view labels — the `defineView()` container is no longer skipped (#4123)

`i18n/missing-view` had **zero producers**. `collectExpectedEntries` recognized
two view shapes and the compiled config is neither:

1. **Object-nested `listViews`** — objects do not carry `listViews` once
compiled (0 across every example).
2. **Top-level named views** — guarded by `if (!view?.name) continue`.

`defineView()` emits the aggregated View **container**, `{ list, listViews,
formViews }`, which per spec (`view.zod.ts`) has **no top-level `name`**: it is
keyed implicitly by its target object at `list.data.object`, exactly as
objectql's `resolveMetadataItemName` resolves it. So the guard rejected the
spec's own container shape, and with it every view in every example — 64 view
strings that the ratchet reported as fully covered.

The walker now handles the container, emitting under the same
`objects.<object>._views.<view>.*` convention the runtime resolver reads
(`viewLabel` in `@object-ui/i18n`) and the shipped platform bundles already
carry. An unnamed default `list` resolves under `_views.list`, matching the
console's `primary.name || 'list'`. `formViews` stays uncovered — form views
have no counterpart in that resolver convention, so keys for them would expect
translations nothing reads.

`StrictObjectTranslation` gains the `_views` slot that
`ObjectTranslationDataSchema` already permits. Without it, `satisfies
StrictObjectTranslation<…>` rejects the very translations the gate now asks
for.

The newly surfaced strings are **translated, not ratcheted** (the precedent set
when the object-less action landed): `check-i18n-coverage` stays at 665 with
none new.
15 changes: 15 additions & 0 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
discount_percent: { label: '折扣 (%)' },
owner_id: { label: '负责人' },
},
_views: {
list: { label: '全部商机' },
all: { label: '全部商机' },
pipeline: { label: '商机看板' },
},
},
crm_lead: {
label: '线索',
Expand All @@ -138,6 +143,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
lead_score: { label: '线索评分' },
source: { label: '来源' },
},
_views: {
list: { label: '全部线索' },
all: { label: '全部线索' },
pipeline: { label: '线索看板' },
},
},
crm_activity: {
label: '活动',
Expand All @@ -150,6 +160,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
contact: { label: '联系人' },
opportunity: { label: '商机' },
},
_views: {
list: { label: '全部活动' },
all: { label: '全部活动' },
calendar: { label: '活动日历' },
},
},
},
apps: {
Expand Down
37 changes: 37 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ export const ShowcaseTranslationBundle = {
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
},
_views: {
list: { label: '全部项目' },
by_status: { label: '按状态' },
budget_chart: { label: '按客户预算' },
},
},
showcase_task: {
label: '任务',
Expand Down Expand Up @@ -211,6 +216,22 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
_views: {
list: { label: '全部任务' },
in_progress: { label: '进行中' },
urgent: { label: '紧急' },
done: { label: '已完成' },
tabular: { label: '任务清单' },
grid: { label: '表格' },
board: { label: '看板' },
cards: { label: '卡片' },
calendar: { label: '日历' },
timeline: { label: '活动时间线' },
gantt: { label: '甘特图' },
map: { label: '工作地点地图' },
chart: { label: '工时按状态分布' },
legacy_row_actions: { label: '旧式行操作' },
},
},
showcase_account: {
label: '客户',
Expand Down Expand Up @@ -287,6 +308,22 @@ export const ShowcaseTranslationBundle = {
label: '分类', pluralLabel: '分类',
fields: { name: { label: '名称' }, parent: { label: '上级' }, color: { label: '颜色' }, sort_order: { label: '排序' } },
},
// `_views` only: these two objects have no zh-CN block of their own, and
// their object/field debt is inside the ratchet's baseline. Translating
// the VIEW labels the coverage fix newly surfaces is what keeps the gate
// from widening; the rest is left exactly as it was.
showcase_inquiry: {
_views: {
list: { label: '客户询问' },
triage: { label: '询问分流' },
},
},
showcase_business_unit: {
_views: {
list: { label: '全部单元' },
org_chart: { label: '组织架构图' },
},
},
showcase_field_zoo: {
label: '字段动物园', pluralLabel: '字段动物园',
fields: { f_lookups: { label: '查找 → 客户(多值)' } },
Expand Down
5 changes: 5 additions & 0 deletions examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export const jaJP: TranslationData = {
notes: { label: 'メモ' },
category_color: { label: 'カテゴリ色' },
},
_views: {
list: { label: 'すべてのタスク' },
overdue: { label: '期限切れのタスク' },
due_today: { label: '本日期限' },
},
},
},
apps: {
Expand Down
5 changes: 5 additions & 0 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export const zhCN: TranslationData = {
notes: { label: '备注' },
category_color: { label: '分类颜色' },
},
_views: {
list: { label: '全部任务' },
overdue: { label: '逾期任务' },
due_today: { label: '今日到期' },
},
} satisfies TaskTranslation,
},
apps: {
Expand Down
84 changes: 72 additions & 12 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,36 @@ export interface ExtractResult {

// ─── Walk helpers ──────────────────────────────────────────────────────

/**
* The object a view binds to.
*
* The last two arms cover the aggregated View CONTAINER (`{ list, listViews,
* formViews }`), which per spec carries no object of its own and is keyed
* implicitly by its inner data source — the same fallback chain objectql's
* `resolveMetadataItemName` uses to register it.
*/
function viewObjectName(view: any): string | undefined {
return view?.objectName ?? view?.object ?? view?.data?.object;
return (
view?.objectName ??
view?.object ??
view?.data?.object ??
view?.list?.data?.object ??
view?.form?.data?.object
);
}

/**
* Emit label / description / emptyState for ONE view under
* `objects.<object>._views.<viewName>.*` — the convention the runtime resolver
* reads (`viewLabel` / `viewDescription` / `viewEmptyState` in
* @object-ui/i18n) and the one the shipped platform bundles already carry
* (`en.objects.generated.ts`: `sys_user._views.all_users.label`).
*/
function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: string, view: any): void {
const root = ['objects', objectName, '_views', viewName];
pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName });
pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName });
pushViewEmptyState(out, root, view, objectName);
}

/**
Expand Down Expand Up @@ -367,10 +395,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
// Object-nested listViews (object-protocol view bundle).
if (obj.listViews && typeof obj.listViews === 'object') {
for (const [viewName, raw] of Object.entries<any>(obj.listViews)) {
const view = raw ?? {};
pushDerived(out, ['objects', objectName, '_views', viewName, 'label'], view.label ?? viewName, inlineText(view.label), 'view', { objectName });
pushOptional(out, ['objects', objectName, '_views', viewName, 'description'], view.description, 'view', { objectName });
pushViewEmptyState(out, ['objects', objectName, '_views', viewName], view, objectName);
pushViewEntries(out, objectName, viewName, raw ?? {});
}
}

Expand All @@ -389,15 +414,50 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
}
}

// ── Top-level views (legacy / cross-object) ──────────────────────
// ── Top-level views ──────────────────────────────────────────────
// Two shapes reach `config.views`, and only one of them has a `name`:
//
// 1. An independent ViewItem — carries a top-level `name` and binds to its
// object via `object`.
//
// 2. The aggregated View CONTAINER `defineView()` emits:
// `{ list, listViews, formViews }`. Per spec (`view.zod.ts`) it has NO
// top-level `name` — it is keyed implicitly by its target object, which
// lives at `list.data.object` (objectql's `resolveMetadataItemName`
// says the same).
//
// Guarding the loop on `view.name` therefore skipped every container, and a
// container is what `defineView()` — i.e. every example and every app that
// authors views this way — actually produces. Objects do not carry
// `listViews` once compiled either, so BOTH view branches were dead and
// `i18n/missing-view` had zero producers repo-wide while the ratchet
// reported green (#4123).
//
// `formViews` stays uncovered: form views have no counterpart in the
// `viewLabel` / `_views.*` resolver convention, so emitting keys for them
// would expect translations nothing reads.
const views: any[] = Array.isArray(config?.views) ? config.views : [];
for (const view of views) {
if (!view?.name) continue;
const objectName = viewObjectName(view);
if (!objectName) continue;
pushDerived(out, ['objects', objectName, '_views', view.name, 'label'], view.label ?? view.name, inlineText(view.label), 'view', { objectName });
pushOptional(out, ['objects', objectName, '_views', view.name, 'description'], view.description, 'view', { objectName });
pushViewEmptyState(out, ['objects', objectName, '_views', view.name], view, objectName);
const containerObject = viewObjectName(view);
if (!containerObject) continue;

if (view.name) {
pushViewEntries(out, containerObject, view.name, view);
continue;
}

// The container's default list. The console ids an unnamed default as
// `primary.name || 'list'` (app-shell `ObjectView`), so it resolves under
// `_views.list`.
if (view.list && typeof view.list === 'object') {
pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list);
}
if (view.listViews && typeof view.listViews === 'object') {
for (const [viewName, raw] of Object.entries<any>(view.listViews)) {
// A named list view may retarget another object via its own `data`.
pushViewEntries(out, viewObjectName(raw) ?? containerObject, viewName, raw ?? {});
}
}
}

// ── Top-level actions ────────────────────────────────────────────
Expand Down
16 changes: 16 additions & 0 deletions packages/spec/src/system/translation-typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,20 @@ export type StrictObjectTranslation<Obj extends { fields: Record<string, unknown
label: string;
pluralLabel?: string;
fields: StrictFieldTranslations<Obj['fields']>;
/**
* View translations keyed by view name — the `_views` slot
* `ObjectTranslationDataSchema` already permits. Not derivable from `Obj`
* (views are declared separately from the object), so it stays optional and
* loosely keyed rather than enumerated like `fields`. Without it, `satisfies
* StrictObjectTranslation<…>` rejects the very translations the view
* coverage gate asks for.
*/
_views?: Record<
string,
{
label?: string;
description?: string;
emptyState?: { title?: string; message?: string };
}
>;
};
Loading