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);