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
32 changes: 28 additions & 4 deletions content/docs/protocol/objectui/concept.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -583,22 +583,46 @@ layout:

### 2. Contextual Actions

Actions appear based on state:
Actions appear based on state. `visible` and `disabled` are **CEL predicate strings**
(or a plain boolean, or a `{ dialect, source }` envelope) — never filter objects:

```yaml
actions:
- type: standard_edit
label: Edit
visible: { status: { $ne: 'locked' } } # Hide if locked
visible: "has(record.status) && record.status != 'locked'" # Hide while locked
- type: workflow_approve
label: Approve
visible: { permissions: { canApprove: true } } # Hide if no permission
requiredPermissions: [approve_invoice] # Hidden in the UI AND refused with 403
- type: standard_delete
label: Delete
confirm: true # Require confirmation
disabled: { has_children: true } # Disable if has child records
disabled: "has(record.has_children) && record.has_children == true" # Disable if has child records
```

<Callout type="warn">
**Two rules turn the obvious spelling of these into bugs.**

**1. The predicate is bare CEL, and the bound record is sparse — guard every read with**
**`has()`.** A filter object (`{ status: { $ne: 'locked' } }` — Mongo query syntax) is in
none of the three arms `visible`/`disabled` declare, so it is a parse error. And the
record an action predicate sees is whatever the client already fetched: on `list_item`
that is only what the view's `$select` projected, so an unprojected column is *absent*,
not `null`, and CEL aborts the whole expression at key resolution. Here the abort is
**fail-closed** — the button is simply not offered, which looks exactly like the
predicate having said no, and nothing logs it. (Note the polarity is the opposite of a
form field or section predicate, which faults *open*.) See
[Actions → Visibility & Disabled Rules](/docs/protocol/objectui/actions#visibility--disabled-rules)
for the guard to write per predicate shape.

**2. Hiding a button is UX, not authorization** — the button is gone, the route is not.
That is why the approve action above declares `requiredPermissions` instead of a
`visible` predicate over some `permissions` map: under [ADR-0066](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0066-unified-authorization-model.md) D4 that one
declaration is dual-surface — the platform action route answers **403** when the caller
lacks the capability (the source of truth) and the UI hides the button from the same
declaration. A `visible` predicate would only do the second half.
</Callout>

### 3. Conditional Field Visibility

Fields appear based on other field values. The key is **`visibleWhen`**, and its value
Expand Down
35 changes: 25 additions & 10 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,25 +352,40 @@ sections:

### Conditional Sections

Show sections based on field values or permissions:
Show sections based on field values. The key is **`visibleWhen`** and its value is a
**CEL predicate string** — never a `{ field, value }` rule object:

```yaml
sections:
- label: Basic Info
fields: [name, email]

- label: Billing Information
visible:
field: account_type
value: premium # Only show for premium accounts
visibleWhen: "record.account_type == 'premium'" # Only show for premium accounts
fields: [payment_method, billing_address]

- label: Admin Settings
visible:
permission: admin # Only show to admins
fields: [api_key, rate_limit]
```

<Callout type="warn">
**There is no section-level `visible` key — and no permission test belongs here.**
`FormSectionSchema` is `.strict()` and declares `visibleWhen` (plus the `@deprecated`
alias `visibleOn`); `visible` is refused *by name*, before any value shape is examined,
and the rejection points you back at `visibleWhen`. Neither a `{ field, value }` rule
object nor a `{ permission }` name is a value `visibleWhen` accepts either — it takes a
CEL predicate string, normalized to a `{ dialect: 'cel', source }` envelope at parse.

Earlier revisions of this page also showed an `Admin Settings` section gated on
`permission: admin`. It is **removed rather than translated**, because rewriting it as a
`visibleWhen` position test would teach the anti-pattern this page warns about further
down — twice over. Nothing server-side evaluates a form-view section predicate, so a
position test here hides the controls and protects no data: the record still carries
every value in the section, and every other read surface still returns them. Worse, on
the console's public form route (`/f/:slug`) no host publishes a predicate scope, so the
root is unbound, the predicate **faults open**, and the section it was meant to hide is
shown to everyone. To withhold a group of fields from some users, declare
[field-level security](/docs/permissions/field-level-security) on a permission set, or
[row-level security](/docs/permissions/rls) — both enforced on the server.
</Callout>

### Section Variants

```yaml
Expand Down
Loading