Skip to content

Commit e7eceec

Browse files
os-zhuangclaude
andauthored
feat(spec): add SelectOption.visibleWhen; generalize Field.dependsOn (objectui#2284) (#2635)
Add a per-option CEL visibility predicate `SelectOption.visibleWhen` for select/multiselect/radio fields — the option is offered only when TRUE, evaluated against the live record + `current_user` (same binding environment as a field-level `visibleWhen`). Expresses cascading/dependent options (`record.country == 'cn'`) and role/context gating (`'admin' in current_user.roles`) without a bespoke dependent-picklist matrix. Shared by `Field.options` and view `FormField.options`. Generalize the `Field.dependsOn` description to be mechanism-neutral (declares dependent sibling fields for both lookup candidate-scoping and select option gating); `{field,param}` stays lookup-only. Client-side hiding is UX only — authorization-gated values must be rejected server-side by the rule-validator. Claude-Session: https://claude.ai/code/session_019aYFm77ddLp6tzuJjt9hK2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent e128a77 commit e7eceec

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Add `SelectOption.visibleWhen` — a per-option CEL visibility predicate for
6+
`select`/`multiselect`/`radio` fields. The option is offered only when the
7+
predicate is TRUE, evaluated against the live record + `current_user` (same
8+
binding environment as a field-level `visibleWhen`). This expresses cascading /
9+
dependent options (`record.country == 'cn'`) and role/context gating
10+
(`'admin' in current_user.roles`) without a bespoke dependent-picklist matrix.
11+
12+
`Field.dependsOn`'s description is generalized to be mechanism-neutral: it
13+
declares the sibling field(s) a field's available values depend on (gating +
14+
re-evaluation), for both lookups (candidate query scoping) and selects
15+
(per-option `visibleWhen` gating). The `{field,param}` form remains lookup-only.
16+
17+
Serializable and shared by `Field.options` and view `FormField.options`.
18+
Client-side hiding is UX only — authorization-gated option values must also be
19+
rejected server-side by the rule-validator.

packages/spec/src/data/field.zod.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ export const SelectOptionSchema = lazySchema(() => z.object({
9191
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
9292
color: z.string().optional().describe('Color code for badges/charts'),
9393
default: z.boolean().optional().describe('Is default option'),
94+
/**
95+
* Per-option visibility predicate (CEL) — the option is offered only when this
96+
* evaluates TRUE. Omit = always available. Evaluated against the SAME binding
97+
* environment as field-level `visibleWhen` (live `record` + `current_user`), so
98+
* it expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND
99+
* role/context gating (`'admin' in current_user.roles`). When it references
100+
* sibling fields, declare those on the field's `dependsOn` so the form can gate
101+
* and re-evaluate the option list as the parent changes.
102+
*
103+
* ⚠️ Client-side hiding is UX, not authorization. When an option is gated for
104+
* access-control reasons the server MUST also reject writes of its value (the
105+
* rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the
106+
* dropdown alone is bypassable.
107+
*/
108+
visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as field visibleWhen (record + current_user). e.g. P`record.country == 'cn'` or P`'admin' in current_user.roles`"),
94109
}));
95110

96111
/**
@@ -495,7 +510,7 @@ export const FieldSchema = lazySchema(() => z.object({
495510
dependsOn: z.array(z.union([z.string(), z.object({
496511
field: z.string(),
497512
param: z.string().optional(),
498-
})])).optional().describe('Dependent lookup: restrict candidates by the value of other field(s) on the same record. String = same local/remote key; {field,param} when the remote filter key differs.'),
513+
})])).optional().describe("Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent."),
499514
allowCreate: z.boolean().optional().describe('Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field.'),
500515

501516
/** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */

0 commit comments

Comments
 (0)