From 120044530631ac8648d258aeace4627403206fcc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 11:40:07 +0000 Subject: [PATCH] feat(spec): add SelectOption.visibleWhen; generalize Field.dependsOn (objectui#2284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019aYFm77ddLp6tzuJjt9hK2 --- .changeset/select-option-visiblewhen.md | 19 +++++++++++++++++++ packages/spec/src/data/field.zod.ts | 17 ++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/select-option-visiblewhen.md diff --git a/.changeset/select-option-visiblewhen.md b/.changeset/select-option-visiblewhen.md new file mode 100644 index 0000000000..50cd267bb8 --- /dev/null +++ b/.changeset/select-option-visiblewhen.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +--- + +Add `SelectOption.visibleWhen` — a per-option CEL visibility predicate for +`select`/`multiselect`/`radio` fields. The option is offered only when the +predicate is TRUE, evaluated against the live record + `current_user` (same +binding environment as a field-level `visibleWhen`). This expresses cascading / +dependent options (`record.country == 'cn'`) and role/context gating +(`'admin' in current_user.roles`) without a bespoke dependent-picklist matrix. + +`Field.dependsOn`'s description is generalized to be mechanism-neutral: it +declares the sibling field(s) a field's available values depend on (gating + +re-evaluation), for both lookups (candidate query scoping) and selects +(per-option `visibleWhen` gating). The `{field,param}` form remains lookup-only. + +Serializable and shared by `Field.options` and view `FormField.options`. +Client-side hiding is UX only — authorization-gated option values must also be +rejected server-side by the rule-validator. diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 6b6381862c..79b805097d 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -91,6 +91,21 @@ export const SelectOptionSchema = lazySchema(() => z.object({ value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'), color: z.string().optional().describe('Color code for badges/charts'), default: z.boolean().optional().describe('Is default option'), + /** + * Per-option visibility predicate (CEL) — the option is offered only when this + * evaluates TRUE. Omit = always available. Evaluated against the SAME binding + * environment as field-level `visibleWhen` (live `record` + `current_user`), so + * it expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND + * role/context gating (`'admin' in current_user.roles`). When it references + * sibling fields, declare those on the field's `dependsOn` so the form can gate + * and re-evaluate the option list as the parent changes. + * + * ⚠️ Client-side hiding is UX, not authorization. When an option is gated for + * access-control reasons the server MUST also reject writes of its value (the + * rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the + * dropdown alone is bypassable. + */ + 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`"), })); /** @@ -495,7 +510,7 @@ export const FieldSchema = lazySchema(() => z.object({ dependsOn: z.array(z.union([z.string(), z.object({ field: z.string(), param: z.string().optional(), - })])).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.'), + })])).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."), 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.'), /** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */