From e01a5e5898a2dd1aad9aab858f944c76fc4ac214 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:50:34 +0000 Subject: [PATCH] fix(showcase): register `ContactViews` in the stack config (#5420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/ui/views/contact.view.ts` declares the whole "create form != edit form" reference implementation — a default grouped `form` with four named sections, a sparse `formViews.create`, and the list's `addRecord: { mode: 'form', formView: 'create' }` binding — and `src/ui/views/index.ts` exports it. `objectstack.config.ts` named the other four containers on line 23 and line 196 and never named this one. There is no directory scan behind `views:`; the CLI reads exactly that array. So the metadata compiled, type-checked and linted clean while reaching nothing: `nav_contacts` rendered a derived default form instead of the authored one, `addRecord.formView` never bound, and no static pass (`os validate` / `os lint` / `os i18n extract` / the coverage ratchet) could see it either — which is also why #5405's new `i18n/missing-section` gate found 10 headings in the showcase and zero of them under `showcase_contact`. `content/docs/ui/create-vs-edit-form.mdx` cites this file as the live reference implementation the whole time. Registration makes exactly five zh-CN coverage keys newly reachable, measured on the real config rather than predicted: os lint examples/app-showcase/objectstack.config.ts 483 warnings (before) -> 488 (registered, untranslated) -> 484 (translated) + i18n/missing-view objects.showcase_contact._views.list.label + i18n/missing-section objects.showcase_contact._sections.{contact,work,status,notes}.label All five are translated in this commit, following PR #5416's rework: zh-CN takes its words from the vocabulary this bundle already uses, and `en` gains nothing (the default locale is satisfied by the inline labels; echoing the source string only fakes coverage). The ratchet baseline `scripts/i18n-coverage-baseline.json` and `i18n.supportedLocales` are untouched — `check-i18n-coverage: OK (12 config(s), 660 baselined untranslated string(s), none new)`, showcase back to exactly 451. Two guards in `test/seed.test.ts`: * every container the barrel exports reaches `stack.views`, matched by target object (`defineStack` parses the config, so a registered container is a structural copy and never `===` the export). Reverting the config hunk fails it naming `ContactViews`. * the four section names and their zh-CN `_sections` keys are asserted as set EQUALITY, because the two i18n gates read that set in opposite directions — `i18n/missing-section` fails on a declared section with no entry, `translation-target-unknown` on an entry no section declares. With #5422 on main this is the first time the relationship is testable on the real config: the four correctly-translated sections are NOT reported as orphans. The remaining `translation-target-unknown` on `_views.list` is #5164's open contradiction between the three `_views` producers, not new here — the showcase already carried four identical instances (project / task / inquiry / business_unit) before this change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .changeset/showcase-register-contact-views.md | 19 ++++ examples/app-showcase/objectstack.config.ts | 4 +- .../src/system/translations/index.ts | 29 +++++ examples/app-showcase/test/seed.test.ts | 104 ++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 .changeset/showcase-register-contact-views.md diff --git a/.changeset/showcase-register-contact-views.md b/.changeset/showcase-register-contact-views.md new file mode 100644 index 0000000000..730665650e --- /dev/null +++ b/.changeset/showcase-register-contact-views.md @@ -0,0 +1,19 @@ +--- +--- + +fix(showcase): 把 `ContactViews` 注册进 `objectstack.config.ts` 的 `views:`(#5420) + +`examples/app-showcase/src/ui/views/contact.view.ts` 声明了「create form ≠ edit +form」的整套元数据(四个具名段落的默认 `form` + 稀疏的 `formViews.create` + +列表上的 `addRecord.formView: 'create'` 绑定),barrel 也导出了它 —— 但 config +第 23 行的具名 import 与第 196 行的 `views:` 数组都漏了它。`views:` 背后没有目录 +扫描,CLI 读的就是这个数组,所以这份元数据编译通过、类型通过、lint 干净,却既进 +不了运行栈,也进不了任何静态门。后果是 `nav_contacts` 渲染的是派生默认表单而不是 +作者写的那份,而 `content/docs/ui/create-vs-edit-form.mdx` 正把这个文件当作活的 +参考实现引用。 + +注册之后随之可见的 5 个 zh-CN 覆盖键(`_views.list.label` 与 +`_sections.{contact,work,status,notes}.label`)在同一提交内补齐译文,棘轮基线 +`scripts/i18n-coverage-baseline.json` 与 `supportedLocales` 均未改动。 + +仅改示例应用(`examples/app-showcase` 为 private 包),不发布任何包。 diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 2392de916b..97efa6c206 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -20,7 +20,7 @@ import { setupShowcaseExternalDatasource } from './src/system/datasources/extern import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; -import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js'; +import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './src/ui/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset } from './src/ui/datasets/index.js'; @@ -193,7 +193,7 @@ export default defineStack({ // UI apps: [ShowcaseApp], - views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], + views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews], pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], dashboards: [ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard], books: allBooks, diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index ae6eb597b5..4fed39c841 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -303,6 +303,35 @@ export const ShowcaseTranslationBundle = { lead_score: { label: '线索评分' }, notes: { label: '备注' }, }, + // #5420 — `ContactViews` had never been listed in the config's `views:` + // array, so nothing (runtime or gate) could reach these two surfaces. + // Registering the container is what makes them translatable at all. + // + // `_views.list` — the container's DEFAULT list, which the resolver ids + // as `list` (`primary.name || 'list'`). Its source label is the bare + // plural "Contacts", so the object's own `pluralLabel` above is the + // right word; the `全部…` spelling the task/project lists use belongs to + // labels that actually say "All …". + _views: { + list: { label: '联系人' }, + }, + // `_sections` — the four groups of the default edit form in + // `ui/views/contact.view.ts`. Each declares a stable `name`, which is + // the only reason the heading is translatable (`ObjectForm` looks it up + // as `objects.showcase_contact._sections..label` and otherwise + // renders the English `label` verbatim). Wording reuses the vocabulary + // this bundle already established — 状态 and 备注 are exactly the words + // its `stage`-family and `notes` entries use — rather than minting a + // second term per idea. `contact` reads 联系方式 (contact DETAILS, its + // fields being 姓名/邮箱/电话) instead of a circular 联系人 heading + // inside a contact record; `work` follows the `…信息` shape the + // showcase_semantic_zoo headings set. + _sections: { + contact: { label: '联系方式' }, + work: { label: '工作信息' }, + status: { label: '状态' }, + notes: { label: '备注' }, + }, }, showcase_invoice: { label: '发票', diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 714c24e6e9..18acf91c96 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -3,6 +3,29 @@ import { describe, it, expect } from 'vitest'; import stack from '../objectstack.config.js'; import { ShowcaseSeedData } from '../src/data/seed/index.js'; +import * as viewBarrel from '../src/ui/views/index.js'; +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; + +/** + * The object a view CONTAINER targets (#5420). + * + * A container (`defineView({ list, form, formViews })`) carries no top-level + * `name` — per `view.zod.ts` it is keyed implicitly by the object its default + * list or form binds to, the same key objectql's `resolveMetadataItemName` and + * the i18n walker derive. Read structurally rather than through the spec types: + * `data` is a union (an `api`-provider view has no `object` at all), and a guard + * that only compiles for the `object` arm would stop compiling the day a + * showcase view switches provider — noise in a test about registration. + */ +function targetObject(container: unknown): string | undefined { + const objectOf = (node: unknown): string | undefined => { + const data = (node as { data?: unknown } | undefined)?.data; + const object = (data as { object?: unknown } | undefined)?.object; + return typeof object === 'string' ? object : undefined; + }; + const c = container as { list?: unknown; form?: unknown } | undefined; + return objectOf(c?.list) ?? objectOf(c?.form); +} /** * Smoke test — the stack loads and registers the expected breadth of @@ -18,6 +41,87 @@ describe('showcase stack', () => { expect((stack.objects ?? []).length).toBeGreaterThanOrEqual(6); }); + /** + * #5420 — every view container the barrel exports must reach the stack. + * + * `src/ui/views/index.ts` is a barrel, and `objectstack.config.ts` names its + * members one by one in a hand-written import list. There is no directory + * scan behind `views:` — the CLI reads exactly that array — so an export the + * import list forgets is metadata that compiles, type-checks, lints clean and + * is never loaded by anything: no runtime container, and no static pass + * (`os validate` / `os lint` / `os i18n extract` / the coverage ratchet) can + * see it either. `ContactViews` sat that way while + * `content/docs/ui/create-vs-edit-form.mdx` cited it as the live reference + * implementation of "create form ≠ edit form", and the app's `nav_contacts` + * entry rendered the derived default form instead of the authored one. + * + * Matched by TARGET OBJECT, not by object identity: `defineStack` parses the + * config, so `stack.views[i]` is a structural copy and never `===` the + * exported container. A view container carries no top-level `name` (spec: + * `view.zod.ts`) — it is keyed implicitly by the object its default list or + * form binds to, which is the same key objectql's `resolveMetadataItemName` + * and the i18n walker use. + */ + it('registers every view container the barrel exports', () => { + const registered = new Set( + (stack.views ?? []).map((v) => targetObject(v)).filter((o) => o !== undefined), + ); + const missing = Object.entries(viewBarrel) + .filter(([, container]) => { + const object = targetObject(container); + // A container the guard cannot key is reported, never skipped — + // silently passing on an unresolvable one is how this guard would + // rot into the very "looks covered, covers nothing" shape #5420 is. + return object === undefined || !registered.has(object); + }) + .map(([name]) => name); + expect(missing, `exported but absent from \`views:\` → ${missing.join(', ')}`).toEqual([]); + // The barrel is the authority for how many there are; assert it is not + // empty so a barrel that stops exporting cannot make this vacuously green. + expect(Object.keys(viewBarrel).length).toBeGreaterThanOrEqual(5); + expect(registered.has('showcase_contact')).toBe(true); + }); + + /** + * #5420 — the section headings the registration makes translatable, pinned + * from BOTH sides on the real stack. + * + * Registering `ContactViews` is what puts its four named `form.sections` in + * front of the i18n gates, and the two gates read the same set in opposite + * directions: `i18n/missing-section` (#5405) fails on a declared section + * with no bundle entry, `translation-target-unknown` (#5415/#5422) fails on + * a bundle entry no section declares. A test that checked one direction + * would stay green while the other broke, so this asserts set EQUALITY. + * + * Read on the composed stack, not on the imported container: what the gates + * see is `stack.views`, and that is exactly the reachability this issue was + * about. A section with no `name` (the sparse `formViews.create` section) is + * untranslatable by construction — every renderer guards the lookup on + * `name` — so it is deliberately outside the set. + */ + it('keys the contact form sections to exactly what the container declares', () => { + const contact = (stack.views ?? []).find((v) => targetObject(v) === 'showcase_contact'); + const form = (contact as { form?: unknown } | undefined)?.form; + const sections = (form as { sections?: unknown } | undefined)?.sections; + const declared = (Array.isArray(sections) ? sections : []) + .map((s) => (s as { name?: unknown } | undefined)?.name) + .filter((n): n is string => typeof n === 'string') + .sort(); + expect(declared).toEqual(['contact', 'notes', 'status', 'work']); + + const zh = ShowcaseTranslationBundle['zh-CN']?.objects?.showcase_contact as + | { _sections?: Record } + | undefined; + expect(Object.keys(zh?._sections ?? {}).sort()).toEqual(declared); + // Every entry carries an actual translation — an empty or echoed English + // label would satisfy the key set while faking the coverage. + for (const name of declared) { + const label = zh?._sections?.[name]?.label; + expect(label, `zh-CN _sections.${name}.label`).toBeTruthy(); + expect(label).not.toMatch(/^[\x20-\x7e]+$/); + } + }); + it('registers UI, automation, security, and AI metadata', () => { expect((stack.views ?? []).length).toBeGreaterThan(0); expect((stack.dashboards ?? []).length).toBeGreaterThan(0);