diff --git a/.changeset/adr-0085-2548-followups.md b/.changeset/adr-0085-2548-followups.md new file mode 100644 index 0000000000..567bb8e601 --- /dev/null +++ b/.changeset/adr-0085-2548-followups.md @@ -0,0 +1,17 @@ +--- +'@objectstack/lint': patch +'@objectstack/plugin-audit': patch +--- + +ADR-0085 #2548 follow-ups surfaced by the real-backend browser pass: + +- **lint**: new `field-group-shadowed` warning in `validate-semantic-roles` — a + declared fieldGroup whose every visible member is hoisted into the detail + highlight strip (or is the record title) renders on forms but silently never + on detail pages (detail bodies hide the first 4 highlightFields). Warning + tier, same as the other semantic-role rules. +- **plugin-audit**: feed/audit summaries ("Created … / Deleted … / Updated …") + now name the object by its display label ("Semantic Zoo") instead of its API + name ("showcase_semantic_zoo") — these strings render verbatim in the record + Discussion feed and Setup dashboards. Falls back to the API name when the + object definition isn't resolvable. Existing stored rows are unchanged. diff --git a/.changeset/console-092bd859934f.md b/.changeset/console-092bd859934f.md new file mode 100644 index 0000000000..ddc31dbb84 --- /dev/null +++ b/.changeset/console-092bd859934f.md @@ -0,0 +1,48 @@ +--- +"@objectstack/console": patch +--- + +Console (objectui) refreshed to `092bd859934f`. Frontend changes in this range: + +- fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) (#2573) +- feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582) (#2567) +- fix(detail): gate related lists on the current user's child-object read permission (#2359) (#2565) +- feat(flow-designer): connector picker lists dispatchable connectors + marks declarative instances (#2563) +- feat(app-shell): Studio CEL editor for list-view conditional formatting (#1584 / #1582) (#2558) +- fix(fields): resolve lookup chip display name via referenced object schema, not the autonumber-prone key heuristic (#2357) (#2551) +- feat(kanban): accept CEL { condition, style } conditional-formatting rules (#1584 follow-up) (#2550) +- fix(plugin-grid): sniff CSV encoding in import wizard (GB18030 fallback) (#2557) +- feat(detail): editable record highlights on the shared inline-edit draft (#2549) +- fix(spec-bridge/form): #2545 stop dropping spec FormViewSchema keys; normalize legacy groups → sections (#2552) +- feat(flow-designer): localize palette headings + cloud-sync recents + guide (#2553) +- fix(studio): refresh builder top-bar name after a package rename (#2554) +- feat(core): B3 cascading-option guardrail, role-gated demo, ADR + browser e2e (#1583) (#2547) +- feat(list): unify conditional formatting + row-action visibility onto the CEL engine (#1584) (#2544) +- feat(detail): record-level inline edit — shared InlineEditContext + one atomic Save (#2542) +- feat(flow-designer): search box + keyboard nav + recents in the add-node palette (#2543) +- feat(kanban): default card fields to object highlightFields (ADR-0085, #2162) (#2541) +- fix(types/plugin-grid): #1763 declare spec-canonical bulkActions on ObjectGridSchema (#2539) +- fix(attachments): download attachments via authenticated signed URL (framework #2970) +- feat(studio): spec-driven package create/edit/view form in a modal (#2535) +- fix(permissions/fields): #2926 ④⑧ — FLS fail-open + lookup display_field (#2537) +- feat(app-shell): CEL authoring safety for RLS policies — lint, field autocomplete, test-run (#2533) +- fix(auth): gate DeviceAuthPage on features.deviceAuthorization (framework#2874 / #2513) (#2536) +- fix(app-shell): close view config panel on discard in edit mode (#2320) +- feat(metadata-admin): create form-family views through the View create UI (#2531) +- fix(app-shell): render action's objectName as an object selector (#2325) +- fix(components): exit inline edit mode for injected cell editors (#2534) +- fix(attachments): authenticated uploads + friendly denial copy in RecordAttachmentsPanel (#2755) (#2532) +- feat(components): page:tabs honors item-level visibleWhen — conditional tabs (framework#2606) (#2516) +- feat(metadata-admin): page variable source is a component picker (#2328) (#2523) +- fix(studio-design): make object canvas overridable via studio-canvas-preview registry (#2337) (#2527) +- fix(metadata-admin): seed flow createDefaults with required `type` (#2525) +- fix(metadata-admin): give hook create form a createSchema so object renders as a ref:object picker (#2521) +- feat(studio): enforce package namespace prefix at authoring time (framework#2694) (#2524) +- fix(app-shell): render View create-form Object field as ref:object picker (#2526) +- feat(components): add `variant === 'primary'` tie-break to action:bar ordering (#2339) (#2519) +- fix(build): stop TS6059 rootDir errors in dts build across 21 packages (#2520) +- fix(app-nav): exclude record-detail pages from the 'page' nav picker (#2333) (#2517) +- fix(plugin-report): stop TS6059 rootDir errors in dts build (#2334) (#2518) +- feat(flow-designer): add 'position' xRef picker kind for approval approvers / escalateTo (#2778) (#2515) + +objectui range: `cc2156841787...092bd859934f` diff --git a/.objectui-sha b/.objectui-sha index 722306a9fe..87ae99c74b 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -cc2156841787fa6b6e5fc63613782c2a5bb8dbb9 +092bd859934f7bbc2654510aef912062c4c4f3a8 diff --git a/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md b/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md index e21efa3565..0526565e27 100644 --- a/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md +++ b/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md @@ -1,6 +1,6 @@ # ADR-0085: Presentation intent on an object is declared as cross-surface semantic roles, never as per-surface hint blocks (delete `detail`, retire `views.*`, type `stageField`) -**Status**: Proposed (2026-07-02) +**Status**: Accepted (2026-07-16) — proposed 2026-07-02; execution complete through PR4 + real-backend verification, see the as-built addendum **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove trichotomy), [ADR-0078](./0078-no-silently-inert-metadata.md) (completeness gate; "AI is the author, silent no-op manufactures false completion"), [ADR-0032](./0032-unified-expression-layer.md) (validate-by-default, no silent failure), ADR-0079 (`nameField` — the canonical object-level semantic-role precedent) **Supersedes**: the `detail.*` passthrough-block approach adopted by [objectui#2065](https://github.com/objectstack-ai/objectui/issues/2065) and proposed in [objectui#2148](https://github.com/objectstack-ai/objectui/issues/2148) §方案-6 (that mechanism was the right escape hatch *given* the spec of the day; this ADR replaces the mechanism, not the goals — both issues' goals ship) @@ -103,3 +103,15 @@ Warnings (fragile): `Field.group` references an undeclared group key; a declared - **Add `highlightFields` alongside `compactLayout`** — rejected. Two curated "important fields" lists drift; Salesforce solved this the same way: the compact layout drives the highlights panel. (Resolved instead as a rename: one list, the clearer name.) - **Keep the `compactLayout` name** — rejected. The name claims a structure the value doesn't have, misses its own biggest consumer (default list columns), and mismatches the renderer vocabulary that already says "highlight" everywhere. A protocol whose keys are named for what they actually do is itself an AI guardrail; the rename costs one alias (ADR-0079 machinery already exists) and a mechanical in-repo sweep. - **Deprecation window for `views.*`** — rejected as cargo cult: there is no author to deprecate for. Windows are for populations, not for ghosts. + +## As-built addendum (2026-07-16) + +Execution landed across both repos; differences from the proposal are noted inline. + +- **Roles & rename** — `highlightFields` shipped with the ADR-0079 alias machinery; the `compactLayout` alias was then fully retired by framework#2536 (served metadata now carries the canonical key only). `stageField` is spec-typed including the strict-`false` suppression contract (renderer side objectui#2168). +- **§5 shared derivation** — `deriveFieldGroupLayout` lives in `@objectstack/spec` (`packages/spec/src/data/field-group-layout.ts`) and passes group `icon`/`description`/`collapse` through; objectui form/detail synth consume it (the two pre-existing near-copies were deleted). +- **§6 guardrails** — `@objectstack/lint` `validate-semantic-roles`: undeclared `Field.group` reference, declared-but-unreferenced group, dangling `stageField`/`highlightFields` pointers; plus `field-group-shadowed` (added by the #2548 follow-up): a group whose every visible member is hoisted into the detail highlight strip (or is the record title) renders on forms but never on detail pages. +- **PR4 (legacy-path deletion)** — objectui#2546 removed the monolith `DetailView` branch in `RecordDetailView` together with the `detail.renderViaSchema` kill-switch and the `?renderViaSchema=0` debug param; schema-driven is the only path. +- **Cross-surface consumption** — kanban default card fields ride `highlightFields` (objectui#2541), alongside grids/lists/detail strip. +- **Verification** — parse: `@objectstack/spec` suite; served pipeline: `packages/dogfood/test/semantic-roles.dogfood.test.ts`; real-backend browser pass over the four detail shapes (grouped / ungrouped / `stageField: false` / related-heavy): framework#3019, runbook + results in `docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md`, and a permanent Playwright spec in `examples/app-showcase/e2e/detail-shapes.spec.ts`. +- **Consequence surfaced by the browser pass** — because detail bodies hide strip fields, a fully-highlighted group silently disappears from detail pages. Judged working-as-intended (one curated list, every surface) but author-surprising — hence the `field-group-shadowed` warning and the semantic-zoo fixture keeping one non-highlighted member per group. diff --git a/docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md b/docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md index f7ff92011e..6c4bc4f74e 100644 --- a/docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md +++ b/docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md @@ -44,8 +44,12 @@ curl -s localhost:4600/api/v1/health # -> {"status":"ok"} # 2. Console — objectui source (post-#2546) pointed at the backend. CORS is # enabled in dev, so the cross-origin :5180 -> :4600 calls are allowed. +# DEV_PROXY_TARGET matters too: relative-path calls (/api/v1/runtime/config, +# /api/v1/dev/metadata-events) go through the Vite /api proxy, which +# defaults to the dead :3000 without it. cd ../objectui -VITE_SERVER_URL=http://localhost:4600 pnpm --filter @object-ui/console dev # :5180 +VITE_SERVER_URL=http://localhost:4600 DEV_PROXY_TARGET=http://localhost:4600 \ + pnpm --filter @object-ui/console dev # :5180 # 3. Auth + fixtures (better-auth Bearer). Sign in, grab the set-auth-token # header, POST the two semantic-zoo records (not seeded), then drive the @@ -82,16 +86,24 @@ highlighted `status/industry/revenue` do not repeat in its Details body. The untitled bucket) is separately proven over the served pipeline in `semantic-roles.dogfood.test.ts`. -## Out-of-scope observations (not defects in the detail path) - -- `502 /api/v1/runtime/config` and `502 /api/v1/dev/metadata-events` are - **same-origin** requests the Console makes to its own origin (`:5180`); in the - split-origin verify harness the Vite server has nothing there. `initRuntimeConfig` - absorbs the failure by design and the app boots + renders regardless. Not seen - in the normal same-origin (`--ui` / vendored console) deployment. -- `401 /api/v1/approvals/requests?…` (the header approvals badge) is a peripheral - poll unrelated to record rendering; the detail pages loaded their data fine. - Left for separate triage. +## Out-of-scope observations (triaged in the #2548 follow-up) + +- `502 /api/v1/runtime/config` / `502 /api/v1/dev/metadata-events` — **harness + misconfiguration, not a product defect**. The console dev server proxies + `/api` to `DEV_PROXY_TARGET` (default `http://localhost:3000`); this pass set + only `VITE_SERVER_URL`, so relative-path calls hit the proxy's dead default. + Correct invocation (now also in the runbook above): + `VITE_SERVER_URL=http://localhost: DEV_PROXY_TARGET=http://localhost: pnpm --filter @object-ui/console dev`. + In production the SPA and runtime share an origin, so relative paths are by + design. `initRuntimeConfig` absorbed the failure and the app rendered anyway. +- `401 /api/v1/approvals/requests?…` (header approvals badge) — **real latent + defect for split-origin deployments**: `approvalsApi` authenticated with + cookies only (`credentials: 'include'`) while every other console API call + sends the Bearer token, so any deployment where the SameSite cookie doesn't + flow (custom-domain console ↔ API split, or this token-injected harness) + loses the approvals surface. Fixed in the objectui #2548 follow-up — + `approvalsApi` now attaches the stored Bearer token like the rest of the + console. ## Conclusion diff --git a/examples/app-showcase/e2e/detail-shapes.spec.ts b/examples/app-showcase/e2e/detail-shapes.spec.ts new file mode 100644 index 0000000000..308df0de28 --- /dev/null +++ b/examples/app-showcase/e2e/detail-shapes.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; + +/** + * ADR-0085 detail-shape regression (#2548) — permanent form of the one-off + * real-backend browser verification that closed objectui#2546 (PR4: legacy + * monolith detail renderer + `renderViaSchema` kill-switch removed). + * + * Four shapes, one invariant each, all through the single SchemaRenderer / + * `buildDefaultPageSchema` path the console now has: + * - grouped → fieldGroup sections render; `collapse: 'collapsed'` + * actually collapses (Money hides `budget` until opened) + * - ungrouped → flat details body, no section cards + * - stageField:false → NO `record:path` stepper despite a `status` field + * named to bait the heuristic + * - related-heavy → related lists surface as tabs (prominence rule) + * + * Runs against the console the backend serves at /_console (baseURL from + * playwright.config.ts; storageState carries the admin session). The + * semantic-zoo fixtures are objects but not seeded records — each run + * creates its own rows via the REST API. + * + * NOTE: assertions are scoped to what the *pinned* console build supports + * (.objectui-sha). Group icon/description chrome ships with a later objectui + * bump — extend the grouped case then. + */ + +const APP = process.env.SHOWCASE_APP || 'com.example.showcase'; +const API = process.env.SMOKE_API_URL || 'http://localhost:3000'; +const recordUrl = (object: string, id: string) => + `/_console/apps/${APP}/${object}/record/${encodeURIComponent(id)}`; + +const PATH_STEPPER = '[aria-label="Record path"]'; + +let zooId = ''; +let zooLegacyId = ''; +let contosoId = ''; + +test.beforeAll(async ({ request }) => { + const createRecord = async (object: string, data: Record) => { + const res = await request.post(`${API}/api/v1/data/${object}`, { data }); + expect(res.ok(), `create ${object} failed: ${res.status()} ${await res.text()}`).toBeTruthy(); + const body = (await res.json()) as any; + const id = body.id ?? body.record?.id ?? body.data?.id; + expect(id, `no id returned creating ${object}`).toBeTruthy(); + return String(id); + }; + + zooId = await createRecord('showcase_semantic_zoo', { + name: 'E2E Zoo Grouped', + status: 'active', + code: 'ZG-1', + amount: 4200, + budget: 100000, + notes: 'detail-shapes e2e fixture', + }); + zooLegacyId = await createRecord('showcase_semantic_zoo_legacy', { + name: 'E2E Zoo Legacy', + status: 'green', + amount: 99, + }); + + // Contoso is seeded with 2 contacts / 2 invoices / 2 projects — the + // related-heavy shape. Resolve its id instead of assuming one. + const res = await request.get(`${API}/api/v1/data/showcase_account?%24top=50`); + expect(res.ok(), `account list failed: ${res.status()}`).toBeTruthy(); + const rows: any[] = ((await res.json()) as any).records ?? []; + const contoso = rows.find((r) => r?.name === 'Contoso'); + expect(contoso, 'seeded account "Contoso" not found').toBeTruthy(); + contosoId = String(contoso.id); +}); + +async function openRecord(page: import('@playwright/test').Page, url: string) { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 }); + await page.waitForTimeout(1500); + return pageErrors; +} + +test('grouped: fieldGroup sections render and Money starts collapsed', async ({ page }) => { + const errors = await openRecord(page, recordUrl('showcase_semantic_zoo', zooId)); + + // Stepper from stageField: 'status'. + // record:path renders a desktop + a mobile variant — assert the visible one. + await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); + + // Declared groups render as titled sections. Their highlighted members + // (status/amount) are hoisted to the strip; the non-highlighted members + // (code/budget) keep the groups visible in the body. + await expect(page.getByText('Basics', { exact: true })).toBeVisible(); + await expect(page.getByText('Money', { exact: true })).toBeVisible(); + await expect(page.getByText('ZG-1')).toBeVisible(); + + // collapse: 'collapsed' — Budget stays hidden until the header is opened. + await expect(page.getByText('Budget', { exact: true })).toHaveCount(0); + await page.getByText('Money', { exact: true }).click(); + await expect(page.getByText('Budget', { exact: true })).toBeVisible(); + + expect(errors, 'uncaught page errors on grouped detail').toEqual([]); +}); + +test('stageField:false: status renders as a plain field, no stepper', async ({ page }) => { + const errors = await openRecord( + page, + recordUrl('showcase_semantic_zoo_legacy', zooLegacyId), + ); + + // The whole point of `stageField: false`: the status-named select must NOT + // become a chevron path. (objectui#2168 pinned strict-false handling.) + await expect(page.locator(PATH_STEPPER)).toHaveCount(0); + // …but the field itself still renders (as a value, not a lifecycle). + await expect(page.getByText('Green').first()).toBeVisible(); + + expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]); +}); + +test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => { + const errors = await openRecord(page, recordUrl('showcase_account', contosoId)); + + // Heuristic stepper (account.status is a select named "status"). + // record:path renders a desktop + a mobile variant — assert the visible one. + await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); + + // Related lists surface as tabs (ADR-0085 prominence rule). + const detailsTab = page.getByRole('tab', { name: /Details/i }); + await expect(detailsTab).toBeVisible(); + const invoicesTab = page.getByRole('tab', { name: /Invoices/i }); + const projectsTab = page.getByRole('tab', { name: /Projects/i }); + await expect(invoicesTab).toBeVisible(); + await expect(projectsTab).toBeVisible(); + + // Ungrouped shape: the Details body is flat — no fieldGroup section cards. + // (Contoso declares no fieldGroups; spot-check a body field label.) + await expect(page.getByText('Basics', { exact: true })).toHaveCount(0); + + // Related lists self-fetch lazily when their tab is shown. + await invoicesTab.click(); + const panel = page.getByRole('tabpanel'); + await expect(panel).toBeVisible(); + await page.waitForTimeout(1500); + const panelText = (await panel.innerText().catch(() => '')) || ''; + expect(panelText.trim().length, 'invoices tab rendered no content').toBeGreaterThan(0); + + expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]); +}); diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index 59a294a4e2..e0e131d9bc 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -52,7 +52,16 @@ export const Account = ObjectSchema.create({ { label: 'Retail', value: 'retail' }, ], }), - annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2, min: 0 }), + // Explicit ISO code (spec channel: `currencyConfig.defaultCurrency`): a + // currency field without a resolvable code renders as a bare grouped + // number by design (no guessed symbol) — with it, the detail highlight + // strip and grids show "$25,000,000" instead of "25,000,000". + annual_revenue: Field.currency({ + label: 'Annual Revenue', + scale: 2, + min: 0, + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + }), website: Field.url({ label: 'Website' }), hq: Field.location({ label: 'Headquarters' }), status: Field.select({ diff --git a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts index 8c7a475b97..a62b4aa838 100644 --- a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts @@ -42,7 +42,23 @@ export const SemanticZoo = ObjectSchema.create({ ], group: 'basics', }), + // `code`/`budget` are deliberately NOT in highlightFields: detail pages + // hide highlighted fields from the body, so a group whose every member is + // highlighted never renders there (the `field-group-shadowed` lint). Each + // group keeps one non-highlighted member so the grouped detail layout — + // including Money's `collapse: 'collapsed'` — is actually exercised + // in-browser (#2548 follow-up), not just at parse time. + code: Field.text({ label: 'Code', group: 'basics' }), amount: Field.number({ label: 'Amount', group: 'money' }), + // Explicit ISO currency (spec channel: `currencyConfig.defaultCurrency`; + // a bare `currency` key is NOT on the field schema and gets stripped at + // parse). Without a resolvable code, renderers deliberately show a bare + // grouped number — never a guessed symbol. + budget: Field.currency({ + label: 'Budget', + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + group: 'money', + }), notes: Field.textarea({ label: 'Notes' }), }, @@ -50,7 +66,16 @@ export const SemanticZoo = ObjectSchema.create({ stageField: 'status', fieldGroups: [ { key: 'basics', label: 'Basics' }, - { key: 'money', label: 'Money', collapse: 'collapsed' }, + // icon/description ride the served pipeline into the shared + // deriveFieldGroupLayout output (ADR-0085 §5) — declared here so the + // detail/form section chrome for them is dogfooded end-to-end. + { + key: 'money', + label: 'Money', + icon: 'banknote', + description: 'Financial fields — collapsed by default.', + collapse: 'collapsed', + }, ], }); @@ -68,12 +93,14 @@ export const SemanticZooLegacy = ObjectSchema.create({ fields: { name: Field.text({ label: 'Name', required: true }), // Named `status` ON PURPOSE: the stepper heuristic would pick it up — - // `stageField: false` below is what keeps it suppressed. + // `stageField: false` below is what keeps it suppressed. Option `color` + // pins the badge tint (spec-typed; without it renderers fall back to a + // value-hash palette, which colored "Green" amber). status: Field.select({ label: 'Status', options: [ - { label: 'Red', value: 'red', default: true }, - { label: 'Green', value: 'green' }, + { label: 'Red', value: 'red', color: 'red', default: true }, + { label: 'Green', value: 'green', color: 'green' }, ], }), amount: Field.number({ label: 'Amount' }), diff --git a/packages/dogfood/test/semantic-roles.dogfood.test.ts b/packages/dogfood/test/semantic-roles.dogfood.test.ts index b8be25bdb8..426201486b 100644 --- a/packages/dogfood/test/semantic-roles.dogfood.test.ts +++ b/packages/dogfood/test/semantic-roles.dogfood.test.ts @@ -55,10 +55,24 @@ describe('ADR-0085 semantic roles survive the served pipeline', () => { expect(def.fieldGroups).toEqual([ { key: 'basics', label: 'Basics', collapse: 'none' }, - { key: 'money', label: 'Money', collapse: 'collapsed' }, + { + key: 'money', + label: 'Money', + icon: 'banknote', + description: 'Financial fields — collapsed by default.', + collapse: 'collapsed', + }, ]); expect(def.fields?.status?.group).toBe('basics'); expect(def.fields?.amount?.group).toBe('money'); + // Non-highlighted group members (the detail body hides highlighted + // fields, so these keep their groups visible on detail pages). + expect(def.fields?.code?.group).toBe('basics'); + expect(def.fields?.budget?.group).toBe('money'); + // The spec channel for a per-field currency is currencyConfig (a bare + // `currency` key is stripped at parse) — renderers resolve + // field.currency → currencyConfig.defaultCurrency → tenant default. + expect(def.fields?.budget?.currencyConfig?.defaultCurrency).toBe('USD'); }); it('the legacy fixture serves canonical highlightFields only (alias retired)', async () => { @@ -89,8 +103,16 @@ describe('ADR-0085 semantic roles survive the served pipeline', () => { const sections = deriveFieldGroupLayout(def); expect(sections).not.toBeNull(); expect(sections!.map((s) => s.key)).toEqual(['basics', 'money', undefined]); - expect(sections![0].fields).toContain('status'); - expect(sections![1]).toMatchObject({ key: 'money', collapse: 'collapsed', fields: ['amount'] }); + expect(sections![0].fields).toEqual(['status', 'code']); + expect(sections![1]).toMatchObject({ + key: 'money', + collapse: 'collapsed', + // icon/description pass through the shared derivation (ADR-0085 §5) so + // every section renderer gets the same header chrome. + icon: 'banknote', + description: 'Financial fields — collapsed by default.', + fields: ['amount', 'budget'], + }); // Ungrouped trailing bucket keeps `notes` visible (never silently dropped). expect(sections![2].fields).toContain('notes'); }); diff --git a/packages/lint/src/validate-semantic-roles.test.ts b/packages/lint/src/validate-semantic-roles.test.ts index d9c82f9f70..6e4b65a8a4 100644 --- a/packages/lint/src/validate-semantic-roles.test.ts +++ b/packages/lint/src/validate-semantic-roles.test.ts @@ -5,6 +5,7 @@ import { validateSemanticRoles, FIELD_GROUP_UNDECLARED, FIELD_GROUP_EMPTY, + FIELD_GROUP_SHADOWED, SEMANTIC_ROLE_FIELD_UNKNOWN, } from './validate-semantic-roles'; @@ -18,7 +19,11 @@ describe('validateSemanticRoles (ADR-0085)', () => { highlightFields: ['name', 'status'], fieldGroups: [{ key: 'basic', label: 'Basic' }], fields: { + // `code` keeps the group visible on detail pages: `name` is the + // record title (page H1, never in the body) so a name-only group + // would trip rule (d). name: { type: 'text', group: 'basic' }, + code: { type: 'text', group: 'basic' }, status: { type: 'select' }, }, }])); @@ -57,6 +62,72 @@ describe('validateSemanticRoles (ADR-0085)', () => { expect(findings[0].message).toContain('unused'); }); + it('flags a group fully shadowed by the highlight strip (rule d)', () => { + // `money`'s only member is also a highlight → detail bodies hide it, so + // the group never renders on detail pages. + const findings = validateSemanticRoles(stack([{ + name: 'zoo', + highlightFields: ['name', 'status', 'amount'], + fieldGroups: [ + { key: 'basics', label: 'Basics' }, + { key: 'money', label: 'Money' }, + ], + fields: { + name: { type: 'text' }, + status: { type: 'select', group: 'basics' }, + code: { type: 'text', group: 'basics' }, // keeps basics visible + amount: { type: 'number', group: 'money' }, + }, + }])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: FIELD_GROUP_SHADOWED, + path: 'objects[0].fieldGroups', + }); + expect(findings[0].message).toContain('money'); + expect(findings[0].message).toContain('amount'); + }); + + it('rule d counts the title field as hidden-from-body and respects the 4-entry strip cap', () => { + // A group holding only the record title never renders in the body even + // though the title is filtered OUT of the strip. + const titleOnly = validateSemanticRoles(stack([{ + name: 'doc', + highlightFields: ['title', 'status'], + fieldGroups: [{ key: 'head', label: 'Head' }], + fields: { title: { type: 'text', group: 'head' }, status: { type: 'select' } }, + }])); + expect(titleOnly.map((f) => f.rule)).toEqual([FIELD_GROUP_SHADOWED]); + + // Entry #5 is beyond the strip (first 4 after the title filter), so a + // group containing it still renders → clean. + const beyondCap = validateSemanticRoles(stack([{ + name: 'wide', + highlightFields: ['name', 'a', 'b', 'c', 'd', 'e'], + fieldGroups: [{ key: 'tail', label: 'Tail' }], + fields: { + name: {}, a: {}, b: {}, c: {}, d: {}, + e: { type: 'text', group: 'tail' }, + }, + }])); + expect(beyondCap).toEqual([]); + + // Hidden members don't keep a group "visible": a group whose only + // NON-hidden member is highlighted is still shadowed. + const hiddenMember = validateSemanticRoles(stack([{ + name: 'mix', + highlightFields: ['name', 'amount'], + fieldGroups: [{ key: 'money', label: 'Money' }], + fields: { + name: {}, + amount: { type: 'number', group: 'money' }, + legacy: { type: 'text', group: 'money', hidden: true }, + }, + }])); + expect(hiddenMember.map((f) => f.rule)).toEqual([FIELD_GROUP_SHADOWED]); + }); + it('flags stageField pointing at a missing field; false is fine', () => { const bad = validateSemanticRoles(stack([{ name: 'lead', stageField: 'pipeline', fields: { status: {} }, diff --git a/packages/lint/src/validate-semantic-roles.ts b/packages/lint/src/validate-semantic-roles.ts index bd2b771228..c30348b69d 100644 --- a/packages/lint/src/validate-semantic-roles.ts +++ b/packages/lint/src/validate-semantic-roles.ts @@ -20,6 +20,7 @@ export const FIELD_GROUP_UNDECLARED = 'field-group-undeclared'; export const FIELD_GROUP_EMPTY = 'field-group-empty'; +export const FIELD_GROUP_SHADOWED = 'field-group-shadowed'; export const SEMANTIC_ROLE_FIELD_UNKNOWN = 'semantic-role-field-unknown'; export type SemanticRoleSeverity = 'error' | 'warning'; @@ -154,6 +155,53 @@ export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { `previews and the detail highlight strip, in order).`, }); } + + // ── (d) declared group fully shadowed by the detail highlight strip ── + // Detail pages render the first 4 highlightFields as the top strip and + // HIDE those fields from the details body; the record's title field is + // the page H1 and never renders in the body either. A group whose every + // visible member is covered by strip ∪ title therefore renders on FORMS + // but silently never on detail pages — legal, but almost never what the + // author pictured when they declared the group. + const declaredStrings = highlights.filter( + (h): h is string => typeof h === 'string' && h.length > 0, + ); + if (declaredStrings.length > 0 && declaredGroups.size > 0) { + // Mirror the renderer's title resolution: declared role first + // (nameField / primaryField / deprecated displayNameField), else the + // first conventional display-field name present on the object. + const declaredTitle = [obj.nameField, obj.primaryField, obj.displayNameField] + .find((v): v is string => typeof v === 'string' && v.length > 0 && fieldNames.has(v)); + const titleField = declaredTitle + ?? ['name', 'full_name', 'title', 'subject', 'display_name'].find((c) => fieldNames.has(c)); + const stripSet = new Set( + declaredStrings.filter((h) => h !== titleField).slice(0, 4), + ); + const hiddenFromBody = new Set(stripSet); + if (titleField) hiddenFromBody.add(titleField); + + for (const key of declaredGroups) { + const members = Object.entries(fields) + .filter(([, f]) => f?.group === key && f?.hidden !== true) + .map(([fname]) => fname); + if (members.length === 0) continue; // rule (b) already covers empty groups + if (!members.every((m) => hiddenFromBody.has(m))) continue; + findings.push({ + severity: 'warning', + rule: FIELD_GROUP_SHADOWED, + where, + path: `${path}.fieldGroups`, + message: + `${objName}: every field in group "${key}" (${members.join(', ')}) is ` + + `hoisted into the detail highlight strip (or is the record title) — ` + + `the group renders on forms but never on detail pages`, + hint: + `Keep at least one non-highlighted field in "${key}", or remove the ` + + `group if the strip already covers it. (Detail pages show the first ` + + `4 highlightFields as the top strip and hide them from the body.)`, + }); + } + } } return findings; diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index cb6f6168bf..749cf7d839 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -447,12 +447,21 @@ export function installAuditWriters( } const label = recordLabel(after ?? before, recordId ?? ''); + // Summaries are user-facing (the record Discussion feed and Setup + // dashboards render them verbatim), so name the object by its display + // label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo"). + // Best-effort: falls back to the API name when the def isn't resolvable. + const objectDef = getObjectDef(ctx.object); + const objectDisplay = + typeof objectDef?.label === 'string' && objectDef.label.length > 0 + ? objectDef.label + : ctx.object; let summary: string; let activityType: string = activityTypeFor(action); if (action === 'create') { - summary = `Created ${ctx.object} "${label}"`; + summary = `Created ${objectDisplay} "${label}"`; } else if (action === 'delete') { - summary = `Deleted ${ctx.object} "${label}"`; + summary = `Deleted ${objectDisplay} "${label}"`; } else { // ADR-0052 §5b — declarative activity, precedence: a configured semantic // milestone (§5b.2) wins; else a tracked field-change diff ("Stage: @@ -464,7 +473,7 @@ export function installAuditWriters( } else { summary = renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ?? - `Updated ${ctx.object} "${label}"`; + `Updated ${objectDisplay} "${label}"`; } }