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
27 changes: 27 additions & 0 deletions .changeset/cascading-select-visiblewhen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/components': minor
'@object-ui/fields': minor
---

Cascading & role-gated `select` options (#2284).

`select` options now accept a per-option `visibleWhen` CEL predicate — the option
is offered only when it evaluates TRUE against the live record **plus
`current_user`** (same engine/env as a field-level `visibleWhen`). Combined with a
field-level `dependsOn`, this drives dependent selects (country → province → city)
and role/context gating with no bespoke matrix — the same primitives dependent
lookups (#2215) already use.

- `@object-ui/core` exposes `resolveVisibleOptions` / `isOptionGroupGated` /
`resolveDependsOnFields` / `isValueStillOffered` (evaluator), reusing the
canonical `evalFieldPredicate`.
- The form renderer narrows a dependent select's option list, gates the control
with a "Select {parent} first" hint while a `dependsOn` field is empty, and
clears a now-invalid value when the parent changes.
- The standalone `SelectField` widget applies the same resolution via
`dependentValues` + the global predicate scope.

Client-side hiding is UX, not authorization: gate authorization-sensitive option
values on the server too. Aligns with `@objectstack/spec` `SelectOption.visibleWhen`.
79 changes: 78 additions & 1 deletion content/docs/fields/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,73 @@ The Select Field component provides a dropdown for selecting one or more options

<SchemaExample id="fields-select/multi-select" />

## Cascading & Role-Gated Options

An option can carry a `visibleWhen` CEL predicate — it is offered only when the
predicate is TRUE. The predicate is evaluated against the **live record** plus
**`current_user`**, the same engine and binding environment as a field-level
`visibleWhen`. This single mechanism covers two needs:

- **Cascading / dependent options** — narrow a child list by a parent field
(country → province → city).
- **Role / context gating** — offer an option only to certain users.

Declare the sibling field(s) a select reacts to with `dependsOn`. While any is
empty the control is **gated** (a "Select country first" hint) instead of
showing an unfiltered list; once the parent changes, the list re-evaluates and
any now-invalid selection is **cleared automatically** (no stale "China +
California" pair).

### Cascading (country → province)

<SchemaExample id="fields-select/cascading-options" />

```json
{
"type": "form",
"fields": [
{ "name": "country", "label": "Country", "type": "select", "options": [
{ "label": "China", "value": "cn" },
{ "label": "United States", "value": "us" }
]},
{ "name": "province", "label": "Province", "type": "select", "dependsOn": "country", "options": [
{ "label": "Zhejiang", "value": "zj", "visibleWhen": "record.country == 'cn'" },
{ "label": "Guangdong", "value": "gd", "visibleWhen": "record.country == 'cn'" },
{ "label": "California", "value": "ca", "visibleWhen": "record.country == 'us'" },
{ "label": "Texas", "value": "tx", "visibleWhen": "record.country == 'us'" }
]}
]
}
```

Chain a third level (`city`, `dependsOn: "province"`) the same way — the gate and
cascade-clear propagate down the chain.

### Role-gated option

```json
{ "name": "tier", "type": "select", "options": [
{ "label": "Standard", "value": "standard" },
{ "label": "Admin only", "value": "admin_only", "visibleWhen": "'admin' in current_user.roles" }
]}
```

> **Security — hiding is UX, not authorization.** A `visibleWhen` on an option
> only removes it from the dropdown on the client; a determined caller can still
> submit the value. When an option is gated for **access-control** reasons the
> **server must also reject** writes of that value (the rule-validator evaluates
> the picked value's `visibleWhen`). Use option `visibleWhen` for convenience and
> cascades freely; for real authorization, pair it with server-side enforcement.

### When to use options vs. a lookup

`visibleWhen` options are for **small, static dictionaries** (category →
subcategory, a handful of provinces). When the data is large, changes over time,
or is shared across forms (real country/province/city tables, org units, product
catalogs), model each level as a **`lookup`** with `depends_on` instead — the
candidate query is filtered server-side and paginated. See
[Lookup Field](/docs/fields/lookup).

## Field Schema

```plaintext
Expand All @@ -33,16 +100,26 @@ interface SelectFieldSchema {
readonly?: boolean; // Read-only mode
disabled?: boolean; // Disabled state
className?: string; // Additional CSS classes

// Options
options: SelectOption[]; // Available options

// Cascading: sibling field(s) whose value drives this option list. While any
// is empty the field is gated ("Select <parent> first"); it re-evaluates as
// they change. Same knob dependent lookups use.
dependsOn?: string | string[];
}

interface SelectOption {
label: string; // Display label
value: string; // Option value
color?: string; // Badge color (gray, red, blue, etc.)
disabled?: boolean; // Disable specific option

// Per-option visibility predicate (CEL). The option is offered only when TRUE,
// evaluated against the live record + current_user (same engine/env as a
// field-level visibleWhen). Omit = always available.
visibleWhen?: string;
}
```

Expand Down
10 changes: 10 additions & 0 deletions examples/schema-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ import fields_phone_required_phone from './schemas/fields-phone/required-phone.j
import fields_rich_text_html_editor from './schemas/fields-rich-text/html-editor.json' with { type: 'json' };
import fields_rich_text_markdown_editor from './schemas/fields-rich-text/markdown-editor.json' with { type: 'json' };
import fields_select_basic_select from './schemas/fields-select/basic-select.json' with { type: 'json' };
import fields_select_cascading_options from './schemas/fields-select/cascading-options.json' with { type: 'json' };
import fields_select_colored_options from './schemas/fields-select/colored-options.json' with { type: 'json' };
import fields_select_multi_select from './schemas/fields-select/multi-select.json' with { type: 'json' };
import fields_summary_average_of_field_values from './schemas/fields-summary/average-of-field-values.json' with { type: 'json' };
Expand Down Expand Up @@ -3521,6 +3522,15 @@ const REGISTRY: Record<string, Example> = {
},
schema: fields_select_basic_select,
},
'fields-select/cascading-options': {
id: 'fields-select/cascading-options',
meta: {
title: "Cascading Options",
description: "Dependent select — province options narrow to the chosen country via per-option visibleWhen + dependsOn",
category: 'fields-select',
},
schema: fields_select_cascading_options,
},
'fields-select/colored-options': {
id: 'fields-select/colored-options',
meta: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"type": "form",
"showSubmit": false,
"showCancel": false,
"fields": [
{
"name": "country",
"label": "Country",
"type": "select",
"placeholder": "Select country...",
"options": [
{ "label": "China", "value": "cn" },
{ "label": "United States", "value": "us" }
]
},
{
"name": "province",
"label": "Province / State",
"type": "select",
"placeholder": "Select province...",
"dependsOn": "country",
"options": [
{ "label": "Zhejiang", "value": "zj", "visibleWhen": "record.country == 'cn'" },
{ "label": "Guangdong", "value": "gd", "visibleWhen": "record.country == 'cn'" },
{ "label": "California", "value": "ca", "visibleWhen": "record.country == 'us'" },
{ "label": "Texas", "value": "tx", "visibleWhen": "record.country == 'us'" }
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Cascading `select` options in the inline form renderer (#2284).
*
* The form renderer's builtin `case 'select'` must narrow a dependent field's
* options by each option's `visibleWhen` and gate the whole control (a "select
* the parent first" hint) while its `dependsOn` controlling field is empty —
* re-evaluating live as the user edits the parent. We drive the controlling
* field through a plain text `input` (Radix Select can't be driven by synthetic
* DOM events) and assert the gate transition on the dependent select.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';

beforeAll(async () => {
await import('../../../renderers');
}, 30000);

function renderForm(fields: any[]) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
);
}

const cascadeFields = [
{ name: 'country', label: 'Country', type: 'input', defaultValue: '' },
{
name: 'province',
label: 'Province',
type: 'select',
dependsOn: 'country',
options: [
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
],
},
];

describe('form renderer — cascading select (#2284)', () => {
it('gates the dependent select until the controlling field is set, then unlocks', async () => {
renderForm(cascadeFields);

// Country empty → province is gated with a parent-first hint (no combobox).
expect(screen.getByText(/select country first/i)).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();

// Pick a country → the gate lifts and the dependent select becomes usable.
fireEvent.change(screen.getByLabelText(/country/i), { target: { value: 'cn' } });
await waitFor(() => {
expect(screen.queryByText(/select country first/i)).not.toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
});
Loading
Loading