Skip to content

Commit 0890fa7

Browse files
os-zhuangclaude
andauthored
feat(core): B3 cascading-option guardrail, role-gated demo, ADR + browser e2e (#1583) (#2547)
Complete the objectui side of B3 (dynamic field options / cascading selects): a build-time guardrail for per-option `visibleWhen` predicates, a role-gated showcase example, ADR-0058, and a real-browser cascade e2e. The client feature itself shipped in #2284/#2215 (v12.0.0); this fills the remaining objectui checklist. - @object-ui/core: `lintOptionPredicates()` statically validates option `visibleWhen` predicates — CEL syntax (via @objectstack/formula `validateExpression`), unknown `record.<field>` references, and a literal compared against an enum sibling outside its value domain (`record.country == 'chna'`, the #2284 AI-typo case). Conservative: only flags what it can statically prove; role/context predicates (`current_user.*`) and open-domain fields are left alone (no false positives). - examples/schema-catalog: role-gated select demo (`current_user.roles`), wired live into the Select field docs page; a new catalog test runs the guardrail over every shipped example. - e2e/cascading-options.spec.ts: drives the shipped country -> province example on the docs site (no backend), asserting the offered set re-filters live and a stale child value clears. Auto-skips when the docs site isn't served, like docs-smoke. - docs/adr/0058: the B3 decision record (per-option `visibleWhen` + `dependsOn` over `optionsWhen`/`validFor`; dual-side contract; server-enforcement contract). Server-side option-value enforcement and the live-backend e2e remain framework-side (tracked in #1583). Claude-Session: https://claude.ai/code/session_01UFBvVjdHzB29XabjYxe1bN Co-authored-by: Claude <noreply@anthropic.com>
1 parent dea65f7 commit 0890fa7

10 files changed

Lines changed: 732 additions & 4 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@object-ui/core": minor
3+
---
4+
5+
feat(core): build-time guardrail for cascading select option predicates (#1583)
6+
7+
`@object-ui/core` now exports `lintOptionPredicates(fields)` — a static,
8+
conservative validator for the per-option `visibleWhen` CEL predicates that
9+
drive cascading / role-gated `select` options (#2284). An option predicate fails
10+
*closed* — a wrong one makes its option silently never appear — so this catches
11+
the class of bug runtime fail-open can't surface:
12+
13+
- `syntax` — invalid CEL, delegated to `@objectstack/formula`'s
14+
`validateExpression` (no schema hint, so a legitimate `current_user.roles`
15+
reference is never mistaken for an error);
16+
- `unknown-field` — a `record.<name>` reference to a field the form never
17+
declares (a sibling typo);
18+
- `option-literal-not-in-domain` — a literal compared against an *enum* sibling
19+
that is outside its declared option values, e.g. `record.country == 'chna'`
20+
when `country` is `cn`/`us` (the AI-authoring typo #2284 called out).
21+
22+
It only flags what it can statically prove — non-`record.` roots
23+
(`current_user.*`), open-domain fields, and unrecognized shapes are left alone,
24+
so there are no false positives. The schema catalog runs it over every shipped
25+
example. Design recorded in ADR-0058.

content/docs/fields/select.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,14 @@ cascade-clear propagate down the chain.
6363

6464
### Role-gated option
6565

66+
<SchemaExample id="fields-select/role-gated-options" />
67+
6668
```json
67-
{ "name": "tier", "type": "select", "options": [
68-
{ "label": "Standard", "value": "standard" },
69-
{ "label": "Admin only", "value": "admin_only", "visibleWhen": "'admin' in current_user.roles" }
69+
{ "name": "visibility", "type": "select", "options": [
70+
{ "label": "Private (only me)", "value": "private" },
71+
{ "label": "My team", "value": "team" },
72+
{ "label": "Whole organization", "value": "org" },
73+
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.roles" }
7074
]}
7175
```
7276

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# ADR-0058: Cascading & role-gated select options (`option.visibleWhen` + `dependsOn`)
2+
3+
**Status**: Accepted — implemented (2026-07-15)
4+
**Author**: ObjectUI renderer team
5+
**Consumers**: `@object-ui/core`, `@object-ui/fields` (`SelectField`), `@object-ui/components` (form renderer), `@object-ui/plugin-form`, `@objectstack/spec`, `@objectstack/objectql`, every app whose forms need a select/lookup whose choices depend on another field or on who is editing
6+
**Companion to**: ADR-0036 (field-level conditional rules) — this applies the same dual-side, one-dialect philosophy to *option* sets. Supersedes the `optionsWhen` framing in issue #1583.
7+
8+
---
9+
10+
## TL;DR
11+
12+
A select's **available options frequently depend on the rest of the record or on
13+
the actor**: pick a Country, then State lists only that country's states; a
14+
"visibility" field offers *Public* only to admins. These are not widget concerns
15+
— they are data-model rules, authored once on the option and honored everywhere
16+
the object is edited.
17+
18+
We express them by reusing the two primitives ADR-0036 and the dependent-lookup
19+
work (#2215) already established, **not** a new mechanism:
20+
21+
| Knob | Meaning | Enforced on |
22+
| ----------------------- | ------------------------------------------------------------- | ------------------- |
23+
| `option.visibleWhen` | the option is offered when the CEL predicate is TRUE | client (UX) + server (for authorization) |
24+
| `field.dependsOn` | which sibling field(s) the option list reacts to (gate/recompute) | client |
25+
26+
A picklist cascade is therefore `dependsOn` (declares the dependency edge) + per-option
27+
`visibleWhen` (the condition) — structurally identical to a lookup cascade.
28+
29+
## Decision: per-option `visibleWhen`, not a field-level `optionsWhen` or `validFor`
30+
31+
Issue #1583 floated a field-level `optionsWhen` predicate; #2284 settled the
32+
authoring shape as **per-option `visibleWhen` + `dependsOn`**, explicitly
33+
rejecting Salesforce-style `validFor` / `controllingField` / dependency matrices.
34+
Rationale:
35+
36+
1. **Minimal, unified vocabulary.** `dependsOn` is already how a lookup declares
37+
its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036).
38+
Reusing both introduces no new category and no bespoke matrix format.
39+
2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees
40+
`current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades
41+
(`record.country == 'cn'`) **and** role/context gating
42+
(`'admin' in current_user.roles`). `validFor` can only express "varies with
43+
another field's value" — it cannot gate by role. `current_user` is already
44+
bound on every predicate surface (server formula, RLS, client UI gates), so a
45+
role-referencing option predicate needs **zero new binding**.
46+
3. **AI-authoring friendly.** Schemas are increasingly model-generated and
47+
human-reviewed. `visibleWhen: "record.country == 'cn'"` reads as prose and is
48+
a primitive the model has seen countless times; a `validFor` matrix's main
49+
value (a visual editor) does not exist under this authoring model.
50+
4. **`dependsOn``visibleWhen`.** `dependsOn` only declares which sibling
51+
fields drive the list (gating UX + recompute); `visibleWhen` is the predicate.
52+
An option may carry `visibleWhen` with **no** `dependsOn` (a pure role gate).
53+
The two are never coupled.
54+
55+
## Why CEL, and the same engine on both ends
56+
57+
As in ADR-0036, the point of a dual-side rule is that the **client UX and the
58+
persisted server verdict agree** for a given record. Both ends evaluate the
59+
option predicate with the canonical `@objectstack/formula` `ExpressionEngine`
60+
(CEL via `@marcbachmann/cel-js`) — the same dialect, stdlib, and null/missing
61+
semantics the field-level rules use — via the shared `evalFieldPredicate` path.
62+
No parallel client DSL, so no drift.
63+
64+
## Client implementation (objectui)
65+
66+
- **`@object-ui/core``evaluator/optionRules.ts`** exposes four zero-React helpers,
67+
all reusing `evalFieldPredicate`:
68+
- `resolveVisibleOptions(options, record, scope?)` — keep options whose
69+
`visibleWhen` is TRUE against the live record (+ `{ current_user }` scope);
70+
predicate-less options are always kept; a faulting predicate **fails open**
71+
(option kept), matching field-visibility posture.
72+
- `resolveDependsOnFields(dependsOn)` — normalize the spec `dependsOn`
73+
(`string | Array<string | {field,param}>`) to sibling field names.
74+
- `isOptionGroupGated(dependsOn, record)` — TRUE while any `dependsOn` field is
75+
empty; the list is withheld rather than shown unfiltered.
76+
- `isValueStillOffered(value, visibleOptions)` — cascade-clear decision
77+
(scalar and multi-select), so a parent change drops a now-invalid child value.
78+
- **The form renderer** (`@object-ui/components`, `renderers/form/form.tsx`)
79+
watches the live record (`ruleRecord`, every declared field seeded to `null`
80+
first — the missing-key gotcha from ADR-0036), narrows each dependent select's
81+
options, gates the control with a `Select {parent} first` hint while a
82+
`dependsOn` field is empty, and **cascade-clears** a stale value in a reactive
83+
effect (no "China + California" pair ever submits).
84+
- **`SelectField`** (`@object-ui/fields`) applies the identical resolution
85+
standalone via `dependentValues` (the channel dependent lookups use) + the
86+
global `usePredicateScope()` (so `current_user` resolves). Filtered-out options
87+
are absent from the DOM (`data-testid="select-option-<value>"` only for offered
88+
values); a gated field renders `select-empty-<name>`.
89+
- **`ObjectForm`** (`@object-ui/plugin-form`) carries `options` (and thus their
90+
`visibleWhen`) and `dependsOn` through from object metadata onto the generated
91+
`FormField`s.
92+
93+
## Static options vs. query-backed lookups
94+
95+
Two paths, one declaration style:
96+
97+
- **Small, stable dictionaries** (category → subcategory, a handful of statuses,
98+
role gates) → static `options` + `option.visibleWhen`. This ADR.
99+
- **Large / changing / shared data** (countries, org trees, catalogs,
100+
account → contact) → `lookup` + `dependsOn`, with the dependency folded into
101+
the query `$filter` (#2215). The choice is a data-modeling decision, documented
102+
in `skills/objectui/guides/schema-expressions.md`; the authoring surface
103+
(`dependsOn`) is the same either way.
104+
105+
## Build-time guardrail
106+
107+
An option `visibleWhen` fails **closed**: a wrong predicate makes its option
108+
silently never appear, which runtime fail-open cannot surface. The headline case
109+
(#2284) is an AI-authored literal typo — `record.country == 'chna'` when
110+
`country`'s options are `cn`/`us`, so the option is unreachable although the
111+
expression parses and the field exists.
112+
113+
`@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`)
114+
closes this at authoring/CI time with three conservative checks: `syntax` (via
115+
`@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate
116+
`current_user.roles` reference is never flagged), `unknown-field` (a `record.<name>`
117+
naming no declared sibling), and `option-literal-not-in-domain` (a literal
118+
compared against an *enum* sibling that is outside its declared value set). It
119+
only flags what it can statically prove — non-`record.` roots, open-domain
120+
fields, and unrecognized shapes are left alone, so there are no false positives.
121+
The schema catalog runs it over every shipped example
122+
(`examples/schema-catalog/test/option-predicates.test.tsx`).
123+
124+
## Server enforcement (framework) — the authorization half
125+
126+
Hiding an option client-side is **UX, not a security boundary**: a caller can
127+
still submit a hidden value. When an option is gated for **authorization**, the
128+
server must also reject writes of that value. The contract (framework
129+
`@objectstack/objectql`, alongside ADR-0036's `requiredWhen`/`readonlyWhen`):
130+
131+
- On write, evaluate the submitted option value's `visibleWhen` over the merged
132+
record (`{ ...previous, ...patch }`) plus the actor; reject with a
133+
`{ field, code }` violation when FALSE — mirroring how `stripReadonlyWhenFields`
134+
already walks conditional fields (`needsPriorRecord`).
135+
- A predicate that fails to evaluate is **fail-open** and logged (a broken rule
136+
must never block a legitimate write), matching the client and ADR-0036.
137+
- Pure cascades / UX-only gating do not require this; it is specifically the
138+
authorization path.
139+
140+
This ADR records the objectui (client + guardrail) work as shipped; the
141+
server-side option-value enforcement and its live e2e are the framework-side
142+
remainder tracked in #1583.
143+
144+
## Consequences
145+
146+
- Authors express dependent and role-gated choices once, on the option, in the
147+
same CEL they already use for field rules and formulas — no widget wiring, no
148+
new matrix format.
149+
- Client and server cannot drift: identical engine and dialect.
150+
- `option.visibleWhen` is UX by default; for authorization it must be paired with
151+
server enforcement — never rely on client hiding for a security guarantee.
152+
- A wrong option predicate is caught before it ships by `lintOptionPredicates`,
153+
not discovered as a silently-missing choice in production.

e2e/cascading-options.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/**
4+
* B3 browser e2e: cascading / dependent `select` options (#1583).
5+
*
6+
* Drives the shipped `fields-select/cascading-options` example
7+
* (`country` → `province`) rendered live on the docs site — the same form
8+
* renderer and the same `@objectstack/formula` per-option `visibleWhen`
9+
* filtering an app uses, with **no backend**. This is the piece JSDOM component
10+
* tests can't reach: opening a real dropdown and asserting the *offered set*
11+
* changes as the controlling field changes, plus the cascade-clear of a
12+
* now-invalid child value.
13+
*
14+
* The docs site (Next.js) must be served separately — e.g.
15+
* `pnpm --filter @object-ui/site dev` — and pointed at via `DOCS_BASE_URL`
16+
* (default `http://localhost:3000`). If it's unreachable the suite auto-skips,
17+
* mirroring `docs-smoke.spec.ts`. The equivalent live-backend e2e (a cascading
18+
* pair on a real showcase object, with the server rejecting an out-of-set
19+
* value) is tracked framework-side in #1583.
20+
*/
21+
22+
const DOCS_BASE = process.env.DOCS_BASE_URL || 'http://localhost:3000';
23+
const SELECT_PAGE = `${DOCS_BASE}/docs/fields/select`;
24+
25+
let docsAvailable = false;
26+
test.beforeAll(async () => {
27+
try {
28+
const controller = new AbortController();
29+
const timer = setTimeout(() => controller.abort(), 5_000);
30+
const response = await fetch(`${DOCS_BASE}/docs`, { signal: controller.signal });
31+
clearTimeout(timer);
32+
docsAvailable = response.ok;
33+
} catch {
34+
docsAvailable = false;
35+
}
36+
});
37+
38+
/** The Radix Select trigger for a form field, scoped by its `field:<name>` wrapper. */
39+
const trigger = (page: Page, field: string) =>
40+
page.locator(`[data-testid="field:${field}"] [role="combobox"]`);
41+
42+
/** Open `field`'s dropdown, return the offered option labels (sorted), then close it. */
43+
async function offeredOptions(page: Page, field: string): Promise<string[]> {
44+
await trigger(page, field).click();
45+
const listbox = page.getByRole('listbox');
46+
await expect(listbox).toBeVisible();
47+
const labels = (await page.getByRole('option').allInnerTexts()).map((s) => s.trim());
48+
await page.keyboard.press('Escape');
49+
await expect(listbox).toBeHidden();
50+
return labels.sort();
51+
}
52+
53+
/** Open `field`'s dropdown and pick the option named `name`. */
54+
async function pick(page: Page, field: string, name: RegExp): Promise<void> {
55+
await trigger(page, field).click();
56+
const listbox = page.getByRole('listbox');
57+
await expect(listbox).toBeVisible();
58+
await page.getByRole('option', { name }).click();
59+
await expect(listbox).toBeHidden();
60+
}
61+
62+
test('province options re-filter live as country changes, and a stale value clears', async ({ page }) => {
63+
test.skip(!docsAvailable, 'Docs site is not reachable (set DOCS_BASE_URL)');
64+
65+
await page.goto(SELECT_PAGE, { waitUntil: 'domcontentloaded', timeout: 30_000 });
66+
67+
const country = trigger(page, 'country');
68+
await country.scrollIntoViewIfNeeded();
69+
await expect(country).toBeVisible();
70+
// Let the client-side renderer hydrate before driving the Radix control — a
71+
// pre-hydration click is dropped and the dropdown never opens.
72+
await expect(country).toBeEnabled();
73+
await page.waitForTimeout(1500);
74+
75+
// --- Gated: while country is unset the dependent province control is withheld. ---
76+
await expect(trigger(page, 'province')).toHaveCount(0);
77+
78+
// --- country = China → only Chinese provinces are offered. ---
79+
await pick(page, 'country', /china/i);
80+
await expect(trigger(page, 'province')).toBeVisible();
81+
expect(await offeredOptions(page, 'province')).toEqual(['Guangdong', 'Zhejiang']);
82+
83+
// Choose one so we can prove the cascade-clear on the next parent change.
84+
await pick(page, 'province', /zhejiang/i);
85+
await expect(trigger(page, 'province')).toContainText(/zhejiang/i);
86+
87+
// --- country = United States → the offered set flips and the stale value clears. ---
88+
await pick(page, 'country', /united states/i);
89+
expect(await offeredOptions(page, 'province')).toEqual(['California', 'Texas']);
90+
// 'Zhejiang' is no longer offered under country=us, so the widget dropped it.
91+
await expect(trigger(page, 'province')).not.toContainText(/zhejiang/i);
92+
});

examples/schema-catalog/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ import fields_select_basic_select from './schemas/fields-select/basic-select.jso
346346
import fields_select_cascading_options from './schemas/fields-select/cascading-options.json' with { type: 'json' };
347347
import fields_select_colored_options from './schemas/fields-select/colored-options.json' with { type: 'json' };
348348
import fields_select_multi_select from './schemas/fields-select/multi-select.json' with { type: 'json' };
349+
import fields_select_role_gated_options from './schemas/fields-select/role-gated-options.json' with { type: 'json' };
349350
import fields_summary_average_of_field_values from './schemas/fields-summary/average-of-field-values.json' with { type: 'json' };
350351
import fields_summary_count_of_related_records from './schemas/fields-summary/count-of-related-records.json' with { type: 'json' };
351352
import fields_summary_sum_of_field_values from './schemas/fields-summary/sum-of-field-values.json' with { type: 'json' };
@@ -3526,7 +3527,7 @@ const REGISTRY: Record<string, Example> = {
35263527
id: 'fields-select/cascading-options',
35273528
meta: {
35283529
title: "Cascading Options",
3529-
description: "Dependent select — province options narrow to the chosen country via per-option visibleWhen + dependsOn",
3530+
description: "",
35303531
category: 'fields-select',
35313532
},
35323533
schema: fields_select_cascading_options,
@@ -3549,6 +3550,15 @@ const REGISTRY: Record<string, Example> = {
35493550
},
35503551
schema: fields_select_multi_select,
35513552
},
3553+
'fields-select/role-gated-options': {
3554+
id: 'fields-select/role-gated-options',
3555+
meta: {
3556+
title: "Role Gated Options",
3557+
description: "",
3558+
category: 'fields-select',
3559+
},
3560+
schema: fields_select_role_gated_options,
3561+
},
35523562
'fields-summary/average-of-field-values': {
35533563
id: 'fields-summary/average-of-field-values',
35543564
meta: {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"type": "form",
3+
"showSubmit": false,
4+
"showCancel": false,
5+
"fields": [
6+
{
7+
"name": "subject",
8+
"label": "Subject",
9+
"type": "text",
10+
"placeholder": "Short summary..."
11+
},
12+
{
13+
"name": "visibility",
14+
"label": "Visibility",
15+
"type": "select",
16+
"placeholder": "Select visibility...",
17+
"options": [
18+
{ "label": "Private (only me)", "value": "private" },
19+
{ "label": "My team", "value": "team" },
20+
{ "label": "Whole organization", "value": "org" },
21+
{
22+
"label": "Public — external",
23+
"value": "public",
24+
"visibleWhen": "'admin' in current_user.roles"
25+
}
26+
]
27+
}
28+
]
29+
}

0 commit comments

Comments
 (0)