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. */