Repository navigation
Commit 76993f8
fix(plugin-detail,app-shell): a field group's visibleWhen gates its record:details section through the spec's { group } reference (objectui#11630) (#11641)
Fixes #11630
Clause-②: yes
## What was wrong
An object's `fieldGroups[].visibleWhen` gated the group on the entry
form but not on the record detail page. `deriveFieldGroupDetailSections`
rebuilt each group as an enumerated section and dropped the predicate
that the shared derivation (`deriveFieldGroupLayout`,
`@objectstack/spec`) passes through, and `record:details` read none. So
the "Pro details" section of the card's reproduction rendered, header
included, on a `basic` row.
## Route (patch round 1: route B, seat ruling on the card)
`@objectstack/spec` 17.6.0 `RecordDetailsProps.sections[]` is strict. It
declares `group`, the ADR-0085 §5 reference form that inherits a group's
members and presentation, `visibleWhen` included. It declares `fields`
as bare names, and it refuses `visibleWhen` on an enumerated section. So
the fix writes pages in that vocabulary:
- **Synthesizer**
(`packages/plugin-detail/src/synth/buildDefaultPageSchema.ts`):
`resolveDetailSections`, and through it `buildDefaultDetails` /
`buildDefaultTabs` / `buildDefaultPageSchema`, now emit each declared
group as `{ group: KEY, columns }`. The trailing ungrouped section lists
bare field names. The derivation sits behind one package-internal
function, `deriveFieldGroupDetailEntries`. It returns each resolved
section with the group's `visibleWhen` carried BESIDE it, never on it.
The public `deriveFieldGroupDetailSections` returns exactly what it
returned before this card: resolved sections, no predicate.
- **Renderer**
(`packages/plugin-detail/src/renderers/record-details.tsx`):
`record:details` resolves a `{ group }` reference against the object's
`fieldGroups` and evaluates the group's `visibleWhen` against the bound
record. It drops the section, heading and members, when the verdict is
FALSE. It does **not** read `visibleWhen` off an enumerated section, a
key the spec refuses there.
- **Runtime default page**
(`packages/app-shell/src/views/RecordDetailView.tsx`): the console's
default record page builds its own section list, so it now writes `{
group: KEY, showBorder: true }` for each declared group. Without that
change the card's reproduction path stays ungated, because an enumerated
copy cannot carry the predicate. Its ungrouped primary and "More
details" sections are unchanged.
- The `record:details` registration's `sections` input description now
says a `{ group }` reference inherits the group's `visibleWhen`, and
that one written on a section itself is not read.
### One evaluator, the form's
The form renderer gates a `section-divider` row with
`resolveFieldRuleState` (`@object-ui/core`) under `usePredicateScope()`
(`@object-ui/react`). `record:details` makes the same call with the same
three inputs. Both modules are existing peer dependencies of
`@object-ui/plugin-detail`: no `plugin-form` dependency and no copy of
the evaluator.
- `record`: every declared field seeded to `null`, then the defined
values of the bound record, as the form's `ruleRecord` is built. A
relation is collapsed to its stored id (`toPredicateRecord`), because
the edit form reads its record without `$expand` and this page's record
may arrive expanded.
- `previous`: the same row. An edit form binds `previous` to the
persisted row, and the row this page shows is the persisted row.
- `scope`: the host predicate scope (`current_user`, `features`, ...).
A predicate that cannot be evaluated SHOWS the section and warns once.
That is the form's direction (`VISIBLE_WHEN_FAULTED`, ADR-0137 D3),
taken from the shared evaluator rather than restated.
## Consumers of the synthesized sections, and what each does now
| consumer | path | after this PR |
| --- | --- | --- |
| console default record page | `RecordDetailView` builds
`synthParts.sections` from `deriveFieldGroupDetailSections(objectDef)`
and passes them into `buildDefaultPageSchema` | changed: writes `{
group: KEY, showBorder: true }` per group; `RecordContextProvider` gets
`objectSchema={objectDef}`, so the reference resolves |
| console page-assignment probe | `RecordDetailView`'s early
`buildDefaultPageSchema(objectDef, slots)` | no code change; it only
decides whether a page exists |
| Studio page create | `metadata-admin/anchors.ts` `createSeed` stores
`buildDefaultPageSchema(objectDef).regions` | no code change; the seed
now holds `{ group }` references and parses as `RecordDetailsProps` |
| Studio slotted-page preview |
`metadata-admin/previews/PagePreview.tsx` calls
`buildDefaultPageSchema(recordSchema, { slots })` | no code change; its
`RecordContextProvider` gets `objectSchema={recordSchema}`, so the
references resolve |
| authored `{ group }` reference | `record:details` resolves it through
`deriveFieldGroupDetailEntries` | now gated by the group's `visibleWhen`
|
| `deriveFieldGroupDetailSections` callers | public export | output
unchanged from before this card |
Pins that cover them: `RecordDetailView.sectionHeadingsRenderPath-6190`
(mounts `RecordDetailView`), `anchors.page-seed`, `PagePreview`,
`defaultFieldGroupsPage.sectionHeadings` (real synthesized page through
the real registry), and the two new files below.
## Published surface (Clause-②)
The emitted shape changes for grouped objects: BREAKING for code that
reads the synthesized sections (the changeset is `minor`, per AGENTS.md
§9).
- `buildDefaultPageSchema`, `buildDefaultTabs`, `buildDefaultDetails`
and `resolveDetailSections` used to emit each group as an enumerated
copy: `name`, `label`, `icon`, `description`, `collapsible`,
`defaultCollapsed`, `columns`, and `fields` as rich `{ name, label,
type, ... }` descriptors. They now emit `{ group: KEY, columns }`. The
ungrouped section's `fields` are bare names. An explicit `sections`
option is still returned unchanged.
- `record:details` gates a `{ group }` reference with the group's
`visibleWhen`.
- `RecordDetailView` (`@object-ui/app-shell`) writes `{ group: KEY,
showBorder: true }` per group.
- Not added: a type member, zod member, registered `inputs` entry, prop
or i18n key. `deriveFieldGroupDetailEntries` is exported from its module
for the renderer and is not re-exported from the package entry.
Migration (also in the changeset): read a group's heading or members via
`deriveFieldGroupDetailSections(def)` instead of off a synthesized
section. Replace a stored section copied from an older seed (`{ name:
'pro', label: ..., fields: [...] }`) with `{ group: 'pro' }`; only the
reference form inherits the group's `visibleWhen`.
**Spec legality, measured** (installed `@objectstack/spec` 17.6.0,
`RecordDetailsProps.safeParse`):
| input | verdict |
| --- | --- |
| `buildDefaultDetails(def).properties` for a grouped object | OK |
| the `record:details` node inside `buildDefaultPageSchema(def)` | OK |
| control: an enumerated section plus `visibleWhen` | `unrecognized_keys
@ sections.0 [visibleWhen]` |
| control: an enumerated section with a field object | `invalid_type @
sections.0.fields.0` |
This also retires the round-0 finding that the synthesized grouped
sections were refused at every field object.
## Tests
Gates on `75c794c` (route B, after merging `origin/main` at `0abd4f9`),
all under the shared verify lock, from the repo root:
- Targeted, six files (the two new files,
`buildDefaultPageSchema.test.ts`,
`defaultFieldGroupsPage.sectionHeadings`,
`recordDetailsInputs.spec-parity`,
`buildDefaultPageSchema.strictPayload`): `Test Files 6 passed (6)`,
`Tests 147 passed (147)`.
- `pnpm exec vitest run packages/plugin-detail/`: `Test Files 238 passed
| 1 skipped (239)`, `Tests 2382 passed | 8 skipped (2390)`.
- `pnpm exec vitest run packages/app-shell/`: `Test Files 1001 passed |
1 skipped (1002)`, `Tests 9951 passed | 9 skipped (9960)`.
- `turbo run build --filter='@object-ui/app-shell^...'`: exit 0. `pnpm
--filter @object-ui/plugin-detail type-check`: exit 0. `lint`: exit 0
for both packages, 0 errors.
- `pnpm --filter @object-ui/app-shell type-check` was red on `75c794c`
(TS2345: `flatMap` inferred its element type from the first return).
Fixed in `594b154` and `aeddcd7` by annotating the element type as
unknown-valued records, a type-only change.
Rerun on final HEAD `aeddcd7` under the lock: app-shell type-check exit
0; app-shell lint exit 0, 0 errors, with the warning count equal to
`75c794c`; the three app-shell consumer pins
(`RecordDetailView.sectionHeadingsRenderPath-6190`, `anchors.page-seed`,
`PagePreview`): `Tests 15 passed (15)`; the two new files: `Tests 18
passed (18)`. The full plugin-detail and app-shell suites ran on
`75c794c`, whose runtime code `aeddcd7` does not change. Unlocked root
checks on `594b154`, each exit 0: `check:new-line-citations` (0 new),
`check:control-bytes`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:test-path-roots`,
`check:phantom-deps`, `check:installed-pin-claims`,
`check:esm-specifiers`, `check:spec-symbols`,
`scripts/check-changeset-presence.mjs`. On `aeddcd7`,
`check:new-line-citations`, `check:control-bytes`,
`check:changeset-claims`, `check:test-path-roots`, `check:phantom-deps`
and the changeset presence check were rerun, exit 0 each.
`renderers/__tests__/record-details.groupVisibleWhen-11630.test.tsx`
renders every case twice on the same row: through `record:details`, fed
by `buildDefaultDetails` (now `{ group }` references), and through the
entry form renderer (`@object-ui/components` `form`), given the
section-divider row `projectSectionDivider` emits for a group, as an
edit form of that row. Each leg asserts the form's verdict, the detail
page's verdict and the expected verdict on `[basic, pro]`:
| predicate | expected |
| --- | --- |
| envelope `record.kind == 'pro'` (the card) | hidden, shown |
| bare string, same source | hidden, shown |
| no predicate (control) | shown, shown |
| bare identifier `kind == 'pro'` (unbound, so it faults) | shown, shown
|
| parse error | shown, shown |
| `record.tier == 'gold'`, a declared field no row carries | hidden,
hidden |
| `record.owner == 'u2'`, a lookup that arrives expanded here | hidden,
shown |
| `previous.kind == 'pro'` | hidden, shown |
| `'sales_manager' in current_user.positions`, denied then allowed |
hidden, shown |
Further legs:
- An authored `{ group: 'pro' }` reference: hidden, shown, on both
spellings.
- The runtime default page's `{ group, showBorder }` shape: hidden,
shown.
- An enumerated section carrying a spec-refused `visibleWhen`: shown,
shown, because it is not read.
Every render reads an ungated anchor first and throws `HARNESS DEAD`
before any verdict.
`synth/__tests__/buildDefaultPageSchema.groupVisibleWhen-11630.test.ts`
pins the emitted references, the absence of `visibleWhen` and field
objects on every section the page writes, the two-control legality
reading above, and the predicate travelling beside the resolved section.
### Ablation on route B (one-shot, on `75c794c`)
Each mutation went through `ablation-replace.mjs`: an anchor that must
hit once, a blob change verified on disk, and a restore proven by blob
equal to HEAD with an empty `git diff HEAD`. Afterwards the diff against
HEAD was 0 bytes and the two new files passed again (18/18). The red
direction was predicted before running.
| mutation | predicted | observed |
| --- | --- | --- |
| drop the gate in `resolveSectionGroup` | 8 gated legs red; 4 controls
and 6 synth legs green | 8 failed, 10 passed |
| emit the old enumerated copy instead of `{ group }` | 3 synth shape
and legality legs, plus the 6 gated matrix legs fed by
`buildDefaultDetails`, red | 9 failed, 9 passed |
| read `visibleWhen` off an enumerated section | only the
enumerated-not-read leg red | 1 failed, 17 passed |
| bind `previous` to nothing | only the `previous` leg red | 1 failed,
11 passed |
| skip the relation collapse | only the lookup leg red | 1 failed, 11
passed |
| skip the `null` seed | only the declared-but-absent leg red | 1
failed, 11 passed |
No `HARNESS DEAD` or partial-group error appeared in any ablation run.
**NOT MEASURED: live before/after on a running backend.** Reason: the
shared objectstack checkout has no built CLI (`packages/cli/dist` is
absent). A backend needs a fresh objectstack worktree plus a full
app-showcase closure build, and that build goes through the shared
verify lock, which stayed saturated this session. The console path is
covered in two halves, not by a live run. The `RecordDetailView` mount
pin shows that its references resolve and the group headings render. The
runtime-default-page leg shows that the same `{ group, showBorder }`
shape is gated.
## Acceptance notes
- The predicate is evaluated against the bound, persisted record, not an
unsaved inline-edit draft.
- A page stored from an older Studio seed keeps its enumerated copy of a
group and is not gated. The changeset's migration line covers it.
- `RecordDetailView`'s ungrouped primary and "More details" sections
still carry rich field descriptors. That page is synthesized at runtime
and never stored or parsed, and route B does not need them changed.
- The spec's `deriveFieldGroupLayout` TSDoc calls the section gate
fail-closed. The form, and now the detail page, fail open on a faulted
predicate. This PR keeps parity with the form; the seat carries that
question to the maintainer.
Attribution: implemented in session
`https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c000398 commit 76993f8
8 files changed
Lines changed: 781 additions & 57 deletions
File tree
- .changeset
- packages
- app-shell/src/views
- plugin-detail/src
- renderers
- __tests__
- synth
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2274 | 2274 | | |
2275 | 2275 | | |
2276 | 2276 | | |
2277 | | - | |
| 2277 | + | |
2278 | 2278 | | |
2279 | 2279 | | |
2280 | 2280 | | |
2281 | 2281 | | |
2282 | 2282 | | |
2283 | | - | |
2284 | | - | |
2285 | | - | |
2286 | | - | |
2287 | | - | |
2288 | | - | |
2289 | | - | |
2290 | | - | |
2291 | | - | |
| 2283 | + | |
| 2284 | + | |
| 2285 | + | |
| 2286 | + | |
| 2287 | + | |
| 2288 | + | |
| 2289 | + | |
| 2290 | + | |
| 2291 | + | |
| 2292 | + | |
| 2293 | + | |
| 2294 | + | |
2292 | 2295 | | |
2293 | 2296 | | |
2294 | 2297 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
530 | 530 | | |
531 | 531 | | |
532 | 532 | | |
533 | | - | |
| 533 | + | |
534 | 534 | | |
535 | 535 | | |
536 | 536 | | |
| |||
0 commit comments