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
30 changes: 30 additions & 0 deletions .changeset/fieldgroups-visiblewhen-reintroduced.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
"@objectstack/spec": minor
---

feat(spec): re-introduce `fieldGroups[].visibleWhen` — the section predicate slot, this time with its enforcement (#12715)

Accept-set **widening** (one new optional key); additive, no existing metadata
changes meaning.

`fieldGroups[].visibleWhen` existed briefly and was REMOVED under ADR-0085 /
ADR-0049 enforce-or-remove because no surface evaluated it. The consumer now
exists: the objectui section-gating contract renders a fieldGroups-derived
group behind a `section-divider` that carries a membership claim and gates the
whole group — header included — on its own visibility verdict. The maintainer
ruled (2026-08-28, #12715) the slot re-declared together with that enforcement,
closing the enforce-or-remove loop in both directions.

- **`visibleWhen`** on `ObjectFieldGroupSchema` — CEL via
`ExpressionInputSchema`, the ADR-0089 canonical spelling shared with field,
action and row predicates (bare string shorthand normalizes to the
`{ dialect: 'cel', source }` envelope). FALSE, or a faulting predicate
(fail-closed), hides the whole group; TRUE or absent shows it.
- **`deriveFieldGroupLayout` passes the predicate through** to the derived
`FieldGroupSection` verbatim (string or envelope, tolerant of un-parsed
metadata like its collapse-alias handling); evaluation stays the renderer's.
- The `visibleOn` spelling stays rejected, its guidance now pointing at the
real slot instead of a removal notice; the `visibleWhen` tombstone row is
retired (the key is real again).

<!-- adr-0087: not-required (additive-widening) A new optional key on an existing shape: nothing is removed, renamed or re-shaped, no tombstone exists, and `objectstack migrate meta` has nothing to rewrite. Existing metadata parses byte-identically; only metadata that opts into the new key gains behaviour, and that behaviour is enforced by the consuming renderer from day one. -->
11 changes: 7 additions & 4 deletions content/docs/data-modeling/schema-design.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -314,17 +314,20 @@ export const Account = ObjectSchema.create({
| `icon` | `string?` | Lucide/Material icon name for the group header |
| `description` | `string?` | Optional description under the header |
| `collapse` | `'none' \| 'expanded' \| 'collapsed'` (default `'none'`) | Collapse behaviour on every surface: `'none'` = always open (no toggle), `'expanded'` = collapsible and starts open, `'collapsed'` = collapsible and starts closed (ADR-0085; replaces the deprecated `defaultExpanded` flag, which is still accepted as an alias) |
| `visibleWhen` | `Expression?` (CEL) | Section visibility predicate — the whole group, header included, is shown only when it evaluates TRUE, else hidden (fail-closed). Same spelling and CEL vocabulary as field and action predicates (ADR-0089); write a bare string (`visibleWhen: "record.type == 'invoice'"`) or the full expression envelope (ADR-0085 §5) |

### Supported Migrations (MVP)

✅ Add / rename / delete / reorder groups — edit the `fieldGroups` array.
✅ Assign an existing field to a group — set `Field.group`.

✅ Show or hide a whole group conditionally — set `visibleWhen` on the group
(ADR-0085 §5).

⏳ Deferred (future iterations): explicit per-field in-group ordering, nested
groups, group-level visibility predicates. Groups render identically on forms,
modals, and detail pages; when a single page needs a bespoke layout beyond
groups, assign it a custom Page schema instead of adding per-surface hints
here (ADR-0085).
groups. Groups render identically on forms, modals, and detail pages; when a
single page needs a bespoke layout beyond groups, assign it a custom Page
schema instead of adding per-surface hints here (ADR-0085).

---

Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,6 +307,7 @@ const result = ApiMethod.parse(data);
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
Expand DownExpand Up@@ -656,6 +657,7 @@ External datasource binding (ADR-0015)
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
Expand Down
12 changes: 12 additions & 0 deletions docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,3 +115,15 @@ Execution landed across both repos; differences from the proposal are noted inli
- **Cross-surface consumption** — kanban default card fields ride `highlightFields` (objectui#2541), alongside grids/lists/detail strip.
- **Verification** — parse: `@objectstack/spec` suite; served pipeline: `packages/qa/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.

## Addendum (2026-08-28): `fieldGroups[].visibleWhen` re-introduced, with its enforcement

§3 removed the group-level visibility slot under the ADR-0049 enforce-or-remove rule, with the exit condition stated in the same breath: *"re-add with its enforcement when a consumer ships."* That consumer shipped — objectui#6236 landed the section-gating contract (a `section-divider` row carries a membership claim, `FormField.fields`, and the form renderer gates the whole claimed group — header included — on the divider's own visibility verdict). Its six-way ablation matrix then measured the two fieldGroups-derived synthesis paths (ModalForm / DrawerForm `derivedSections`) as structurally fail-open for exactly one reason: this vocabulary had no predicate slot for the divider to carry (#12715).

Maintainer ruling (2026-08-28, #12715): **option A — re-introduce the slot, this time with enforcement**, closing the loop as designed:

- **`fieldGroups[].visibleWhen`** — CEL via `ExpressionInputSchema`, the ADR-0089 canonical spelling shared with field, action and row predicates. No second predicate language, no new semantics.
- **§5's shared derivation** (`deriveFieldGroupLayout`) passes the predicate through to the derived section verbatim (bare string or expression envelope), alongside `icon`/`description`/`collapse`. Evaluation stays the renderer's: grouping is static layout, visibility is per-record state.
- **Whole-group disappearance (header included) rides the landed objectui#6236 contract unchanged** — FALSE (or a faulting predicate, fail-closed) hides the divider and every claimed member.
- The `visibleOn` spelling stays rejected with guidance pointing at `visibleWhen` (the #8382 posture: pointed, not aliased).
- Both directions of the ADR-0049 trichotomy are now on this key's record: removed while unenforced, re-declared when declared = enforced holds on day one.
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -263,6 +263,7 @@
"Field (type)",
"FieldGroupCollapse (type)",
"FieldGroupSection (interface)",
"FieldGroupVisibleWhen (type)",
"FieldInput (type)",
"FieldMaskingKeep (type)",
"FieldMaskingKeepSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -690,6 +690,7 @@
"data/ObjectFieldGroup:icon",
"data/ObjectFieldGroup:key",
"data/ObjectFieldGroup:label",
"data/ObjectFieldGroup:visibleWhen",
"data/PerOperationRequiredPermissions:create",
"data/PerOperationRequiredPermissions:delete",
"data/PerOperationRequiredPermissions:read",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -263,6 +263,7 @@
"Field": "src/data/field.zod.ts#Field (type)",
"FieldGroupCollapse": "src/data/field-group-layout.ts#FieldGroupCollapse (type)",
"FieldGroupSection": "src/data/field-group-layout.ts#FieldGroupSection (interface)",
"FieldGroupVisibleWhen": "src/data/field-group-layout.ts#FieldGroupVisibleWhen (type)",
"FieldInput": "src/data/field.zod.ts#FieldInput (type)",
"FieldMaskingKeep": "src/data/field.zod.ts#FieldMaskingKeep (type)",
"FieldMaskingKeepSchema": "src/data/field.zod.ts#FieldMaskingKeepSchema (const)",
Expand Down
43 changes: 43 additions & 0 deletions packages/spec/src/data/field-group-layout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,49 @@ describe('deriveFieldGroupLayout (ADR-0085 §5)', () => {
})!;
expect(sections[0].label).toBe('billing');
});

// `visibleWhen` passthrough — the ADR-0049 re-introduction WITH enforcement:
// the slot only exists because the renderer's section-gating contract now
// evaluates it, and the derivation's whole job here is to CARRY it, verbatim,
// in both the shapes real metadata arrives in.
describe('visibleWhen passthrough (ADR-0085 §5 re-introduction)', () => {
const derive = (group: Record<string, unknown>) =>
deriveFieldGroupLayout({
fieldGroups: [group],
fields: { a: { group: 'g' } },
})![0];

it('carries a bare CEL string through verbatim (author / bare-DB form)', () => {
expect(derive({ key: 'g', label: 'G', visibleWhen: "record.type == 'invoice'" }).visibleWhen)
.toBe("record.type == 'invoice'");
});

it('carries an Expression envelope through verbatim (post-parse form)', () => {
const envelope = { dialect: 'cel', source: "record.type == 'invoice'" };
expect(derive({ key: 'g', label: 'G', visibleWhen: envelope }).visibleWhen).toBe(envelope);
});

it('omits the key entirely when the group declares no predicate', () => {
expect('visibleWhen' in derive({ key: 'g', label: 'G' })).toBe(false);
});

it('drops non-predicate shapes instead of forwarding them (fail-closed renderer would hide the group)', () => {
expect('visibleWhen' in derive({ key: 'g', label: 'G', visibleWhen: 42 })).toBe(false);
expect('visibleWhen' in derive({ key: 'g', label: 'G', visibleWhen: '' })).toBe(false);
expect('visibleWhen' in derive({ key: 'g', label: 'G', visibleWhen: ['record.x'] })).toBe(false);
expect('visibleWhen' in derive({ key: 'g', label: 'G', visibleWhen: null })).toBe(false);
});

it('never stamps a predicate on the trailing ungrouped bucket', () => {
const sections = deriveFieldGroupLayout({
fieldGroups: [{ key: 'g', label: 'G', visibleWhen: 'record.a > 0' }],
fields: { a: { group: 'g' }, loose: {} },
})!;
const trailing = sections[sections.length - 1];
expect(trailing.key).toBeUndefined();
expect('visibleWhen' in trailing).toBe(false);
});
});
});

/**
Expand Down
33 changes: 31 additions & 2 deletions packages/spec/src/data/field-group-layout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,12 @@
* - hidden fields never surface;
* - `collapse` passes through (deprecated `defaultExpanded` /
* `collapsible`+`collapsed` aliases are honoured for pre-ADR-0085
* metadata that reaches consumers un-normalized, e.g. bare DB rows).
* metadata that reaches consumers un-normalized, e.g. bare DB rows);
* - `visibleWhen` passes through verbatim (bare CEL string or Expression
* envelope — the ADR-0049 re-introduction with enforcement): EVALUATION
* is the renderer's section-gating contract (FALSE hides the whole
* group, header included, fail-closed), not this helper's — grouping is
* static layout, visibility is per-record state.
*
* Returns `null` when grouping does not apply — no declared groups, or no
* visible field references one — so callers fall back to their existing
Expand All@@ -32,6 +37,16 @@
/** Collapse behaviour of a derived section (mirrors ObjectFieldGroupSchema.collapse). */
export type FieldGroupCollapse = 'none' | 'expanded' | 'collapsed';

/**
* Carried section predicate (mirrors `ObjectFieldGroupSchema.visibleWhen`):
* a bare CEL string (author/bare-DB form) or an Expression envelope
* (`{ dialect, source, … }`, the post-parse form). Passed through verbatim —
* the renderer's section-gating contract evaluates it; this module only
* carries it. Structural on purpose: the helper stays dependency-free and
* tolerant of un-parsed metadata, same as its `collapse`-alias handling.
*/
export type FieldGroupVisibleWhen = string | { readonly [key: string]: unknown };

/** One derived section. `key` is absent on the trailing ungrouped bucket. */
export interface FieldGroupSection {
/** Group machine key; i18n anchor (`…objects.{obj}._sections.{key}.label`). Absent = ungrouped bucket. */
Expand All@@ -44,6 +59,8 @@ export interface FieldGroupSection {
description?: string;
/** Collapse behaviour; 'none' when the group declared nothing. */
collapse: FieldGroupCollapse;
/** Section visibility predicate, passed through verbatim; absent = always visible. Never present on the ungrouped bucket. */
visibleWhen?: FieldGroupVisibleWhen;
/** Member field NAMES in field-declaration order. Renderers resolve defs themselves. */
fields: string[];
}
Expand DownExpand Up@@ -86,10 +103,20 @@ export const FIELD_GROUP_SYSTEM_FIELDS: ReadonlySet<string> = new Set([
type AnyRec = Record<string, unknown>;

/** Normalize one declared group entry; null for malformed/keyless entries. */
function readGroup(g: unknown): { key: string; label?: string; icon?: string; description?: string; collapse: FieldGroupCollapse } | null {
function readGroup(g: unknown): { key: string; label?: string; icon?: string; description?: string; collapse: FieldGroupCollapse; visibleWhen?: FieldGroupVisibleWhen } | null {
if (!g || typeof g !== 'object' || Array.isArray(g)) return null;
const grp = g as AnyRec;
if (typeof grp.key !== 'string' || grp.key.length === 0) return null;
// Tolerant passthrough, same posture as the collapse aliases below: a bare
// CEL string or an Expression envelope rides through; any other shape is
// dropped rather than handed to a renderer as a malformed predicate (which
// would fail-closed and hide the group for a value that was never a
// predicate at all).
const vw = grp.visibleWhen;
const visibleWhen: FieldGroupVisibleWhen | undefined =
(typeof vw === 'string' && vw.length > 0) || (typeof vw === 'object' && vw !== null && !Array.isArray(vw))
? (vw as FieldGroupVisibleWhen)
: undefined;
let collapse: FieldGroupCollapse = 'none';
if (grp.collapse === 'expanded' || grp.collapse === 'collapsed' || grp.collapse === 'none') {
collapse = grp.collapse;
Expand All@@ -106,6 +133,7 @@ function readGroup(g: unknown): { key: string; label?: string; icon?: string; de
icon: typeof grp.icon === 'string' ? grp.icon : undefined,
description: typeof grp.description === 'string' ? grp.description : undefined,
collapse,
...(visibleWhen !== undefined ? { visibleWhen } : {}),
};
}

Expand DownExpand Up@@ -155,6 +183,7 @@ export function deriveFieldGroupLayout(def: unknown): FieldGroupSection[] | null
...(g.icon !== undefined ? { icon: g.icon } : {}),
...(g.description !== undefined ? { description: g.description } : {}),
collapse: g.collapse,
...(g.visibleWhen !== undefined ? { visibleWhen: g.visibleWhen } : {}),
fields: names,
});
}
Expand Down
30 changes: 27 additions & 3 deletions packages/spec/src/data/object-strictness-batch20.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -474,9 +474,33 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak
});
});

describe('tombstones — a retired key\'s rejection carries its upgrade', () => {
it('`fieldGroups[].visibleWhen` says it was REMOVED, not misspelled', () => {
const msg = rejectOnObject({ fieldGroups: [{ key: 'g', label: 'G', visibleWhen: 'true' }] });
describe('the re-opened predicate slot — `fieldGroups[].visibleWhen` is a real key again (ADR-0049 loop closed)', () => {
// This block REPLACED a tombstone pin: `visibleWhen` was removed under
// ADR-0085 / ADR-0049 enforce-or-remove while nothing evaluated it, and
// re-declared once the form renderer's section-gating contract shipped.
// The pins flip accordingly — acceptance (both authorable shapes, with the
// ExpressionInputSchema normalization) instead of a curated rejection.
it('accepts the bare-string CEL shorthand and normalizes it to the envelope', () => {
const obj = accept(ObjectSchema, {
...OBJ,
fields: { name: { type: 'text', label: 'Name', group: 'g' } },
fieldGroups: [{ key: 'g', label: 'G', visibleWhen: "record.type == 'invoice'" }],
}) as { fieldGroups?: Array<{ visibleWhen?: unknown }> };
expect(obj.fieldGroups?.[0]?.visibleWhen).toEqual({ dialect: 'cel', source: "record.type == 'invoice'" });
});

it('accepts the full Expression envelope, standalone and through the carrier', () => {
accept(ObjectFieldGroupSchema, { key: 'g', label: 'G', visibleWhen: { dialect: 'cel', source: 'record.amount > 0' } });
accept(ObjectSchema, {
...OBJ,
fields: { name: { type: 'text', label: 'Name', group: 'g' } },
fieldGroups: [{ key: 'g', label: 'G', visibleWhen: { dialect: 'cel', source: 'record.amount > 0' } }],
});
});

it('`visibleOn` stays rejected, and its guidance now names the real slot', () => {
const msg = rejectOnObject({ fieldGroups: [{ key: 'g', label: 'G', visibleOn: 'true' }] });
expect(msg).toContain('visibleWhen');
expect(msg).toContain('ADR-0085');
});
});
Expand Down
Loading
Loading