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
19 changes: 19 additions & 0 deletions .changeset/showcase-register-contact-views.md
Original file line number Diff line number Diff line change
@@ -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 包),不发布任何包。
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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: '发票',
Expand Down
104 changes: 104 additions & 0 deletions examples/app-showcase/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, { label?: string }> }
| 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);
Expand Down
Loading