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
8 changes: 8 additions & 0 deletions .changeset/b2-client-field-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@object-ui/core": minor
"@object-ui/types": minor
"@object-ui/components": patch
"@object-ui/plugin-form": patch
---

B2 step 3: client-side field-level conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`). The form renderer now evaluates these CEL predicates reactively against the live record and gates each field's visibility, read-only state, and required-ness accordingly. Evaluation delegates to the canonical `@objectstack/formula` `ExpressionEngine` — the *same* dialect the server enforces (`requiredWhen` in the rule-validator, `readonlyWhen` in `stripReadonlyWhenFields`) — so the UX and the persisted verdict always agree. New core helpers `evalFieldPredicate` / `resolveFieldRuleState` (zero-React, fail-open). `FormField` gains `visibleWhen` / `readonlyWhen` / `requiredWhen` (+ deprecated `conditionalRequired` alias), and `ObjectForm` carries them through from object metadata.
117 changes: 117 additions & 0 deletions docs/adr/0036-field-conditional-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ADR-0036: Field-level conditional rules (visibleWhen / readonlyWhen / requiredWhen)

**Status**: Accepted — implementing (2026-06-07)
**Author**: ObjectUI renderer team
**Consumers**: `@object-ui/core`, `@object-ui/components` (form renderer), `@object-ui/plugin-form`, `@objectstack/spec`, `@objectstack/objectql`, every app whose forms need a field to appear / lock / become mandatory based on other field values

---

## TL;DR

A field's visibility, read-only state, and required-ness are frequently
**conditional on the rest of the record**: an invoice's `paid_on` is only
relevant once `status == 'paid'`; its `tax_rate` is locked once paid; a "send"
flow requires `issued_on` only when the invoice leaves draft. These are not
widget concerns — they are **data-model rules**, authored once on the field and
honored everywhere the object is edited.

We express them as three optional CEL predicates on `Field`:

| Prop | When the predicate is TRUE | Enforced on |
| -------------- | --------------------------------------------------- | ------------------ |
| `visibleWhen` | the field is shown (else hidden) | client (UX only) |
| `readonlyWhen` | the field is read-only | **client + server**|
| `requiredWhen` | the field is required | **client + server**|

`conditionalRequired` is a back-compat **alias of `requiredWhen`**.

## Why CEL, and why the *same* engine on both ends

The whole point of a dual-side rule is that the **client UX and the persisted
server verdict agree** for any given record. To guarantee that, both ends
evaluate the predicate with the canonical ObjectStack expression engine —
`@objectstack/formula`'s `ExpressionEngine` (CEL via `@marcbachmann/cel-js`) —
rather than a parallel evaluator. Same dialect, same stdlib, same null/missing
semantics. The alternative (a bespoke client-side condition DSL) is exactly the
drift hazard this avoids: it would agree on `record.status == 'paid'` today and
silently diverge the first time a predicate used `has()`, a string function, or
a list membership test.

`@objectstack/formula` is browser-safe — its only deps are
`@marcbachmann/cel-js` and `@objectstack/spec`, and `@object-ui/core` already
depends on the latter — so there is no new runtime surface and no node-only
import dragged into the bundle.

## Server enforcement (framework)

- **`requiredWhen`** — `@objectstack/objectql`'s rule-validator evaluates the
predicate over the *merged* record (`{ ...previous, ...patch }`) and pushes a
`{ field, code: 'required' }` violation when it is TRUE and the value is
missing. `conditionalRequired` is treated identically.
- **`readonlyWhen`** — `stripReadonlyWhenFields` drops any field from an UPDATE
payload whose predicate is TRUE for the merged record: the incoming change is
**ignored** (the persisted value is kept), not rejected. Update paths fetch
the prior record only when an object actually declares conditional fields
(`needsPriorRecord`).
- A predicate that fails to evaluate is **fail-open** and logged (a broken rule
must never block a legitimate write).
- `visibleWhen` is **not** a server concept — visibility is purely a client UX
affordance. The server's `requiredWhen` / `readonlyWhen` are the real guards,
so hiding a field client-side never weakens enforcement.

## Client enforcement (objectui)

- **`@object-ui/core`** exposes two zero-React helpers:
- `evalFieldPredicate(pred, record, fallback, previous?)` — wraps the engine,
returns `fallback` on an absent/broken predicate.
- `resolveFieldRuleState(rules, record, statics, previous?)` → `{ visible,
readonly, required }`. A static `required: true` / `readonly: true` is a
**floor** — a FALSE predicate never weakens it; `visibleWhen` is
authoritative when present.
- **The form renderer** (`@object-ui/components`) watches the live record
(`form.watch()`) and re-evaluates every field's rules **reactively** as the
user types. A field whose `visibleWhen` is FALSE is not rendered; `readonly`
feeds the field's `disabled`; `required` drives both the asterisk and the RHF
validation rule.
- **`ObjectForm`** (`@object-ui/plugin-form`) carries the three props through
from object metadata onto the generated `FormField`s.

### The missing-key gotcha

CEL **throws** on a *missing* map key (`record.status` when `status` isn't in
the record) but compares cleanly against `null`. On a fresh create form,
react-hook-form hasn't registered every field yet, so a naïve `form.watch()`
omits them — and a `visibleWhen` referencing an unregistered field would fault
and fail *open* (flash visible). The renderer therefore seeds every declared
field to `null` before overlaying the *defined* watched values, so an
unregistered field reads as present-null (clean predicate result) rather than
missing (fault). This mirrors the server, which always evaluates over the full
merged record.

`evalFieldPredicate`'s fallbacks are chosen so a fault is *safe*: `true` for
visibility (don't hide content on error), `false` for required/readonly (don't
block submit or lock a field on error) — the same posture as the server.

## Showcase

`showcase_invoice` demonstrates all three:

```ts
issued_on: Field.date({ requiredWhen: "record.status in ['sent', 'paid']" }),
tax_rate: Field.number({ readonlyWhen: "record.status == 'paid'" }),
paid_on: Field.date({
visibleWhen: "record.status == 'paid'", // UX-only: hide until paid
requiredWhen: "record.status == 'paid'", // dual-side
}),
```

Covered by the `field-conditional-rules` live e2e (drives Status →
paid/sent/draft and asserts each dependent field re-gates).

## Consequences

- Authors express conditional UX once, on the field, in the same CEL they
already use for validation rules and formulas — no widget-level wiring.
- Client and server cannot drift: identical engine, identical dialect.
- `visibleWhen` is intentionally client-only; never rely on it for security —
use `readonlyWhen` / `requiredWhen` (or a full validation rule) for guarantees.
65 changes: 65 additions & 0 deletions e2e/live/field-conditional-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test, expect, type Locator } from '@playwright/test';
import { selectOption } from './helpers';

/**
* B2 live e2e: field-level conditional rules (visibleWhen / readonlyWhen /
* requiredWhen), authored as CEL on the object's fields and enforced
* client-side by the form renderer via the canonical @objectstack/formula
* engine — the SAME dialect the server enforces, so the UX and the persisted
* verdict agree.
*
* The showcase Invoice header declares:
* • issued_on.requiredWhen = "record.status in ['sent', 'paid']"
* • tax_rate.readonlyWhen = "record.status == 'paid'"
* • paid_on.visibleWhen = "record.status == 'paid'" (UX-only)
* paid_on.requiredWhen = "record.status == 'paid'"
*
* Driving the Status select must reactively re-gate every dependent field.
*/

/** True when the field's <label> carries the required asterisk. */
async function isRequired(dialog: Locator, labelText: string): Promise<boolean> {
const marker = dialog
.locator('label', { hasText: labelText })
.locator('span[aria-label="required"]');
return (await marker.count()) > 0;
}

test('header fields react to Status via CEL visibleWhen / readonlyWhen / requiredWhen', async ({ page }) => {
await page.goto('/apps/showcase_app/showcase_invoice');
await page.getByRole('button', { name: /^(New|新建)$/i }).first().click();

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();

const paidOn = dialog.locator('[name="paid_on"]');
const taxRate = dialog.locator('[name="tax_rate"]');

// --- Initial (status unset / draft): paid_on hidden, tax_rate editable,
// issued_on not yet required. ---
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
expect(await isRequired(dialog, 'Issued On')).toBe(false);

// --- Paid: tax_rate locks (readonlyWhen), paid_on appears + required,
// issued_on required. ---
await selectOption(dialog, 'status', 'paid');
await expect(paidOn).toHaveCount(1);
await expect(taxRate).toBeDisabled();
expect(await isRequired(dialog, 'Paid On')).toBe(true);
expect(await isRequired(dialog, 'Issued On')).toBe(true);

// --- Sent: tax_rate editable again, paid_on hidden again, issued_on still
// required. ---
await selectOption(dialog, 'status', 'sent');
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
expect(await isRequired(dialog, 'Issued On')).toBe(true);

// --- Draft: everything relaxes — tax_rate editable, paid_on hidden,
// issued_on optional. ---
await selectOption(dialog, 'status', 'draft');
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
expect(await isRequired(dialog, 'Issued On')).toBe(false);
});
52 changes: 47 additions & 5 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import { ComponentRegistry } from '@object-ui/core';
import { ComponentRegistry, resolveFieldRuleState } from '@object-ui/core';
import type { FormSchema, FormField as FormFieldConfig, ValidationRule, FieldCondition, SelectOption } from '@object-ui/types';
import { useForm } from 'react-hook-form';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from '../../ui/form';
Expand Down Expand Up @@ -223,6 +223,30 @@ ComponentRegistry.register('form',
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [submitError, setSubmitError] = React.useState<string | null>(null);

// Live snapshot of all form values — subscribes to every change so
// field-level CEL rules (visibleWhen/readonlyWhen/requiredWhen) re-evaluate
// reactively as the user edits. Evaluated below via the canonical
// `@objectstack/formula` engine (same dialect the server enforces). We seed
// every declared field name to `null` first so a predicate that references
// a field react-hook-form hasn't registered yet (e.g. on initial mount,
// before defaults populate) evaluates against a present-but-null value
// rather than faulting — mirroring the server, which evaluates over the
// full merged record.
const watched = form.watch() as Record<string, unknown>;
const ruleRecord = React.useMemo(() => {
// Seed every declared field to `null` so a predicate referencing a field
// that's absent / not-yet-registered evaluates against a present-null
// value. The canonical CEL engine throws "No such key" on a *missing*
// field (which would fail the predicate open), but compares cleanly
// against `null`. Overlay only DEFINED watched values so an unregistered
// field (value `undefined`) doesn't clobber its null seed back to missing.
const out: Record<string, unknown> = {};
for (const f of fields) if (f?.name) out[f.name] = null;
for (const k of Object.keys(watched)) if (watched[k] !== undefined) out[k] = watched[k];
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields, JSON.stringify(watched)]);

// Read DataSource from SchemaRendererContext and propagate it to field
// widgets as a prop so they can dynamically load related records.
const schemaCtx = React.useContext(SchemaRendererContext);
Expand Down Expand Up @@ -412,15 +436,19 @@ ComponentRegistry.register('form',
label,
description,
type = 'input',
required = false,
required: staticRequired = false,
disabled: fieldDisabled = false,
validation = {},
condition,
colSpan,
hidden,
widget,
visibleOn,
readonly,
readonly: staticReadonly,
visibleWhen,
readonlyWhen,
requiredWhen,
conditionalRequired,
...fieldProps
} = field;

Expand All @@ -431,10 +459,10 @@ ComponentRegistry.register('form',
if (condition) {
const watchField = condition.field;
const watchValue = form.watch(watchField);

// Check for null/undefined before evaluating conditions
const hasValue = watchValue !== undefined && watchValue !== null;

if (condition.equals !== undefined && watchValue !== condition.equals) {
return null;
}
Expand All @@ -446,6 +474,20 @@ ComponentRegistry.register('form',
}
}

// Field-level CEL conditional rules (B2). Evaluated reactively
// against the live record via the canonical engine — same
// dialect the server enforces (requiredWhen / readonlyWhen), so
// the UX and the persisted verdict agree. A field with no rules
// resolves to its static flags unchanged.
const ruleState = resolveFieldRuleState(
{ visibleWhen, readonlyWhen, requiredWhen, conditionalRequired },
ruleRecord,
{ required: staticRequired, readonly: staticReadonly === true },
);
if (!ruleState.visible) return null;
const required = ruleState.required;
const readonly = ruleState.readonly;

// Section divider — renders a collapsible FormSection header inline
// so all fields share the same form instance (enables cross-section conditions).
if (type === 'section-divider') {
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"dependencies": {
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^7.9.0",
"@objectstack/spec": "^7.8.0",
"lodash": "^4.18.1",
"zod": "^4.4.3"
Expand Down
Loading
Loading