From fcfbcb10235bfb316e93b985edb9012e3bd11155 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:44:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(forms):=20client-side=20CEL=20field=20?= =?UTF-8?q?rules=20=E2=80=94=20visibleWhen/readonlyWhen/requiredWhen=20(B2?= =?UTF-8?q?-PR3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluate field-level conditional rules reactively in the form renderer via the canonical @objectstack/formula ExpressionEngine — the same dialect the server enforces (requiredWhen in rule-validator, readonlyWhen in stripReadonlyWhenFields) — so the client UX and the persisted verdict always agree. - core: evalFieldPredicate / resolveFieldRuleState (zero-React, fail-open) - types: FormField gains visibleWhen/readonlyWhen/requiredWhen (+ deprecated conditionalRequired alias) - components: form renderer watches the live record, gates visible/readonly/ required per field - plugin-form: ObjectForm carries the rule props through from object metadata - 16 unit tests Co-Authored-By: Claude Opus 4.8 --- .changeset/b2-client-field-rules.md | 8 ++ .../components/src/renderers/form/form.tsx | 34 +++++- packages/core/package.json | 1 + .../evaluator/__tests__/fieldRules.test.ts | 110 ++++++++++++++++++ packages/core/src/evaluator/fieldRules.ts | 104 +++++++++++++++++ packages/core/src/evaluator/index.ts | 1 + packages/plugin-form/src/ObjectForm.tsx | 8 +- packages/types/src/form.ts | 28 +++++ pnpm-lock.yaml | 35 ++++++ 9 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 .changeset/b2-client-field-rules.md create mode 100644 packages/core/src/evaluator/__tests__/fieldRules.test.ts create mode 100644 packages/core/src/evaluator/fieldRules.ts diff --git a/.changeset/b2-client-field-rules.md b/.changeset/b2-client-field-rules.md new file mode 100644 index 000000000..befc55c2a --- /dev/null +++ b/.changeset/b2-client-field-rules.md @@ -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. diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index ecb425e89..c67b61daf 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -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'; @@ -223,6 +223,12 @@ ComponentRegistry.register('form', const [isSubmitting, setIsSubmitting] = React.useState(false); const [submitError, setSubmitError] = React.useState(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). + const recordValues = form.watch() as Record; + // 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); @@ -412,7 +418,7 @@ ComponentRegistry.register('form', label, description, type = 'input', - required = false, + required: staticRequired = false, disabled: fieldDisabled = false, validation = {}, condition, @@ -420,7 +426,11 @@ ComponentRegistry.register('form', hidden, widget, visibleOn, - readonly, + readonly: staticReadonly, + visibleWhen, + readonlyWhen, + requiredWhen, + conditionalRequired, ...fieldProps } = field; @@ -431,10 +441,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; } @@ -446,6 +456,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 }, + recordValues, + { 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') { diff --git a/packages/core/package.json b/packages/core/package.json index 114f33a38..fc024611c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/evaluator/__tests__/fieldRules.test.ts b/packages/core/src/evaluator/__tests__/fieldRules.test.ts new file mode 100644 index 000000000..1089d90b9 --- /dev/null +++ b/packages/core/src/evaluator/__tests__/fieldRules.test.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { evalFieldPredicate, resolveFieldRuleState } from '../fieldRules'; + +describe('evalFieldPredicate', () => { + it('evaluates a bare-string CEL predicate as TRUE', () => { + expect(evalFieldPredicate("record.status == 'paid'", { status: 'paid' }, false)).toBe(true); + }); + + it('evaluates a bare-string CEL predicate as FALSE', () => { + expect(evalFieldPredicate("record.status == 'paid'", { status: 'draft' }, false)).toBe(false); + }); + + it('accepts an { dialect, source } object predicate', () => { + expect( + evalFieldPredicate({ dialect: 'cel', source: 'record.amount > 100' }, { amount: 250 }, false), + ).toBe(true); + }); + + it('returns the fallback for an absent predicate', () => { + expect(evalFieldPredicate(undefined, { status: 'paid' }, true)).toBe(true); + expect(evalFieldPredicate(null, { status: 'paid' }, false)).toBe(false); + expect(evalFieldPredicate(' ', { status: 'paid' }, true)).toBe(true); + }); + + it('fails open to the fallback on a broken predicate', () => { + // Unparseable / type-faulting CEL must not throw — it returns fallback. + expect(evalFieldPredicate('record.status ===', { status: 'paid' }, false)).toBe(false); + expect(evalFieldPredicate('record.status ===', { status: 'paid' }, true)).toBe(true); + }); + + it('exposes previous.* for transition predicates', () => { + expect( + evalFieldPredicate("record.status == 'paid' && previous.status != 'paid'", { status: 'paid' }, false, { + status: 'sent', + }), + ).toBe(true); + }); +}); + +describe('resolveFieldRuleState', () => { + it('hides a field whose visibleWhen is FALSE', () => { + const s = resolveFieldRuleState({ visibleWhen: "record.status == 'sent'" }, { status: 'draft' }, {}); + expect(s.visible).toBe(false); + }); + + it('shows a field whose visibleWhen is TRUE', () => { + const s = resolveFieldRuleState({ visibleWhen: "record.status == 'sent'" }, { status: 'sent' }, {}); + expect(s.visible).toBe(true); + }); + + it('shows a field with no visibleWhen (default visible)', () => { + expect(resolveFieldRuleState({}, {}, {}).visible).toBe(true); + }); + + it('locks a field whose readonlyWhen is TRUE', () => { + const s = resolveFieldRuleState({ readonlyWhen: "record.status == 'paid'" }, { status: 'paid' }, {}); + expect(s.readonly).toBe(true); + }); + + it('keeps a static readonly even when readonlyWhen is FALSE', () => { + const s = resolveFieldRuleState( + { readonlyWhen: "record.status == 'paid'" }, + { status: 'draft' }, + { readonly: true }, + ); + expect(s.readonly).toBe(true); + }); + + it('requires a field whose requiredWhen is TRUE', () => { + const s = resolveFieldRuleState({ requiredWhen: "record.status == 'sent'" }, { status: 'sent' }, {}); + expect(s.required).toBe(true); + }); + + it('honors conditionalRequired as a requiredWhen alias', () => { + const s = resolveFieldRuleState({ conditionalRequired: "record.status == 'sent'" }, { status: 'sent' }, {}); + expect(s.required).toBe(true); + }); + + it('prefers requiredWhen over conditionalRequired when both present', () => { + const s = resolveFieldRuleState( + { requiredWhen: "record.status == 'sent'", conditionalRequired: 'record.amount > 0' }, + { status: 'draft', amount: 5 }, + {}, + ); + // requiredWhen (status=='sent') is FALSE → not required; the alias is ignored. + expect(s.required).toBe(false); + }); + + it('keeps a static required even when requiredWhen is FALSE', () => { + const s = resolveFieldRuleState( + { requiredWhen: "record.status == 'sent'" }, + { status: 'draft' }, + { required: true }, + ); + expect(s.required).toBe(true); + }); + + it('fails open: broken visibleWhen keeps the field visible', () => { + const s = resolveFieldRuleState({ visibleWhen: 'record.status ===' }, { status: 'x' }, {}); + expect(s.visible).toBe(true); + }); +}); diff --git a/packages/core/src/evaluator/fieldRules.ts b/packages/core/src/evaluator/fieldRules.ts new file mode 100644 index 000000000..b259d52a9 --- /dev/null +++ b/packages/core/src/evaluator/fieldRules.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ + +/** + * Client-side evaluation of field-level conditional rules + * (`visibleWhen` / `readonlyWhen` / `requiredWhen`). + * + * These predicates are authored as CEL — the *same* dialect the server + * enforces in `@objectstack/objectql`'s rule-validator (`requiredWhen`) and + * `stripReadonlyWhenFields` (`readonlyWhen`). To guarantee the client UX and + * the server enforcement reach the *identical* verdict for a given record we + * delegate to the canonical engine, `@objectstack/formula`'s + * `ExpressionEngine`, rather than re-implementing a parallel evaluator. See + * ADR-0036 (field-level conditional rules) and the framework's + * `packages/formula` "No private expression DSL" note. + * + * A predicate is `string | { dialect, source }`. A bare string is treated as + * CEL (mirrors the server's `toExpression`). Evaluation is *fail-open* for + * visibility/required (a broken predicate must not hide a field or wrongly + * block submit) and *fail-open* for readonly (a broken predicate leaves the + * field editable) — matching the server, which logs and allows the change + * through. + */ +import { ExpressionEngine } from '@objectstack/formula'; +import type { Expression } from '@objectstack/spec'; + +/** A field-rule predicate as authored in metadata. */ +export type FieldRulePredicate = string | { dialect?: string; source: string }; + +/** Normalize a predicate into the `Expression` shape the engine expects. */ +function toExpression(pred: FieldRulePredicate): Expression { + if (typeof pred === 'string') return { dialect: 'cel', source: pred }; + return { dialect: (pred.dialect ?? 'cel') as Expression['dialect'], source: pred.source }; +} + +/** + * Evaluate a field-rule CEL predicate against a record. + * + * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate. + * @param record The live form values (overlays prior persisted record). + * @param fallback Value to return when the predicate is absent or fails to + * evaluate. Pick the *safe* default for the caller: + * `false` for readonly/required (don't lock/block on error), + * `true` for visibility (don't hide on error). + * @param previous The prior persisted record, if any (for `previous.*` refs). + */ +export function evalFieldPredicate( + pred: FieldRulePredicate | undefined | null, + record: Record, + fallback: boolean, + previous?: Record, +): boolean { + if (pred == null || (typeof pred === 'string' && !pred.trim())) return fallback; + try { + const res = ExpressionEngine.evaluate(toExpression(pred), { record, previous }); + if (!res.ok) return fallback; + return res.value === true; + } catch { + return fallback; + } +} + +/** + * Resolve the effective `{ visible, readonly, required }` state for a field + * given its conditional rules and the live record. Each `*When` rule, when + * present, *overrides* the static flag. A static `true` is never weakened by a + * `false` predicate result for required/readonly — but `visibleWhen` is + * authoritative when present (so a field can be conditionally shown/hidden). + */ +export function resolveFieldRuleState( + rules: { + visibleWhen?: FieldRulePredicate; + readonlyWhen?: FieldRulePredicate; + requiredWhen?: FieldRulePredicate; + /** Back-compat alias of `requiredWhen` (spec @deprecated). */ + conditionalRequired?: FieldRulePredicate; + }, + record: Record, + statics: { required?: boolean; readonly?: boolean }, + previous?: Record, +): { visible: boolean; readonly: boolean; required: boolean } { + const visible = + rules.visibleWhen != null + ? evalFieldPredicate(rules.visibleWhen, record, true, previous) + : true; + + const readonly = + statics.readonly === true || + (rules.readonlyWhen != null + ? evalFieldPredicate(rules.readonlyWhen, record, false, previous) + : false); + + const requiredPred = rules.requiredWhen ?? rules.conditionalRequired; + const required = + statics.required === true || + (requiredPred != null ? evalFieldPredicate(requiredPred, record, false, previous) : false); + + return { visible, readonly, required }; +} diff --git a/packages/core/src/evaluator/index.ts b/packages/core/src/evaluator/index.ts index 90a5527ad..c94a64e63 100644 --- a/packages/core/src/evaluator/index.ts +++ b/packages/core/src/evaluator/index.ts @@ -8,6 +8,7 @@ export * from './ExpressionContext.js'; export * from './ExpressionEvaluator.js'; +export * from './fieldRules.js'; export * from './ExpressionCache.js'; export * from './FormulaFunctions.js'; export * from './SafeExpressionParser.js'; diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 54894f681..2d10a9db6 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -454,8 +454,14 @@ const SimpleObjectForm: React.FC = ({ placeholder: field.placeholder, description: field.help || field.description, validation: buildValidationRules(field), + // Field-level CEL conditional rules (B2). Carried through verbatim so + // the form renderer evaluates them reactively via the canonical + // engine (same dialect the server enforces). Undefined when absent. + visibleWhen: field.visibleWhen, + readonlyWhen: field.readonlyWhen, + requiredWhen: field.requiredWhen ?? field.conditionalRequired, // Important: Pass the original field metadata so widgets can access properties like precision, currency, etc. - field: field, + field: field, }; // Add field-specific properties diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 0ac565d87..d80416825 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -851,6 +851,34 @@ export interface FormField { * @default false */ readonly?: boolean; + /** + * CEL predicate: when it evaluates TRUE for the live record the field is + * shown, when FALSE it is hidden. Aligns with @objectstack/spec + * Field.visibleWhen and is enforced client-side via the canonical + * `@objectstack/formula` engine (same dialect the server uses). A broken + * predicate fails open (field stays visible). + * @example "record.status == 'sent'" + */ + visibleWhen?: string | { dialect?: string; source: string }; + /** + * CEL predicate: when TRUE the field becomes read-only (the server also + * strips matching writes via `stripReadonlyWhenFields`). Aligns with + * @objectstack/spec Field.readonlyWhen. Fails open (field stays editable). + * @example "record.status == 'paid'" + */ + readonlyWhen?: string | { dialect?: string; source: string }; + /** + * CEL predicate: when TRUE the field is required (the server enforces the + * same rule in its rule-validator). Aligns with @objectstack/spec + * Field.requiredWhen. Fails open (field is not required). + * @example "record.status == 'sent'" + */ + requiredWhen?: string | { dialect?: string; source: string }; + /** + * @deprecated Back-compat alias of {@link requiredWhen}. Aligns with + * @objectstack/spec Field.conditionalRequired. + */ + conditionalRequired?: string | { dialect?: string; source: string }; /** * Additional field-specific props */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d75cf19f..737047861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1030,6 +1030,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/formula': + specifier: ^7.9.0 + version: 7.9.0(ai@6.0.194(zod@4.4.3)) '@objectstack/spec': specifier: ^7.8.0 version: 7.8.0(ai@6.0.194(zod@4.4.3)) @@ -3735,6 +3738,11 @@ packages: '@maplibre/vt-pbf@4.3.0': resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} + '@marcbachmann/cel-js@7.6.1': + resolution: {integrity: sha512-YyY8yiDU07ByKb2rJUoNgMAkYeeDvkdrONBREV2MP9siAW7yNft0KfbIoonWTIMY0fGtv8og0EhzKx3Xsi1Nmg==} + engines: {node: '>=20.19.0'} + hasBin: true + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -3882,6 +3890,9 @@ packages: resolution: {integrity: sha512-Whvubqw2nSZeSf6s6JEHheszq+zwBhSKdiOTCfDa9MZdamtHrCztBH90DnFBkUiNQHzrvHbaUag6KxGie3DuOA==} engines: {node: '>=18.0.0'} + '@objectstack/formula@7.9.0': + resolution: {integrity: sha512-58RShr27FiB/zNydIRi+pEkmnnR/lQe3uWlS/5o6J5UHtLERSRTPv0qqHiMIjHvLGVtx3qUeSnnGBVrFQJeqPA==} + '@objectstack/spec@7.8.0': resolution: {integrity: sha512-eOdt6KVi8D52mGxV1slwzEqvnejW5teDhDk20lBWVAvn4XVN5DT0NVGjSPfABaKvoFKItk0lS+7Ur/p0NgIEQA==} engines: {node: '>=18.0.0'} @@ -3891,6 +3902,15 @@ packages: ai: optional: true + '@objectstack/spec@7.9.0': + resolution: {integrity: sha512-B/tEB4Xggdwck9v4d97dB+K3dOjgjjm/TALcRqSFotRmBs3hbsiLXxKYMsyhl71E57/DNxwwikaZ3inwOi+ZUA==} + engines: {node: '>=18.0.0'} + peerDependencies: + ai: ^6.0.0 + peerDependenciesMeta: + ai: + optional: true + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -11579,6 +11599,8 @@ snapshots: pbf: 4.0.1 supercluster: 8.0.1 + '@marcbachmann/cel-js@7.6.1': {} + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -11771,12 +11793,25 @@ snapshots: transitivePeerDependencies: - ai + '@objectstack/formula@7.9.0(ai@6.0.194(zod@4.4.3))': + dependencies: + '@marcbachmann/cel-js': 7.6.1 + '@objectstack/spec': 7.9.0(ai@6.0.194(zod@4.4.3)) + transitivePeerDependencies: + - ai + '@objectstack/spec@7.8.0(ai@6.0.194(zod@4.4.3))': dependencies: zod: 4.4.3 optionalDependencies: ai: 6.0.194(zod@4.4.3) + '@objectstack/spec@7.9.0(ai@6.0.194(zod@4.4.3))': + dependencies: + zod: 4.4.3 + optionalDependencies: + ai: 6.0.194(zod@4.4.3) + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} From 19b397cb7a97a2ddc144dec024b85522f77f0326 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:12:53 +0800 Subject: [PATCH 2/2] feat(forms): seed null for unregistered fields, add live e2e + ADR-0036 + skill (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes B2 client conditional rules: - form renderer seeds every declared field to null before overlaying defined watched values, so a visibleWhen/etc. predicate referencing a not-yet- registered field compares against null instead of faulting on a missing key (CEL "No such key") and failing open — mirrors the server's full-record merge - core: unit test locking the missing-key fail-open contract (17 tests) - e2e/live: field-conditional-rules.spec — drives Invoice Status through paid/sent/draft and asserts paid_on visibility, tax_rate lock, issued_on required all re-gate reactively (passes in a fresh context) - docs: ADR-0036 (field-level conditional rules) + objectui skill schema-expressions guide section Co-Authored-By: Claude Opus 4.8 --- docs/adr/0036-field-conditional-rules.md | 117 ++++++++++++++++++ e2e/live/field-conditional-rules.spec.ts | 65 ++++++++++ .../components/src/renderers/form/form.tsx | 24 +++- .../evaluator/__tests__/fieldRules.test.ts | 11 ++ skills/objectui/guides/schema-expressions.md | 42 +++++++ 5 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0036-field-conditional-rules.md create mode 100644 e2e/live/field-conditional-rules.spec.ts diff --git a/docs/adr/0036-field-conditional-rules.md b/docs/adr/0036-field-conditional-rules.md new file mode 100644 index 000000000..6e313c2ac --- /dev/null +++ b/docs/adr/0036-field-conditional-rules.md @@ -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. diff --git a/e2e/live/field-conditional-rules.spec.ts b/e2e/live/field-conditional-rules.spec.ts new file mode 100644 index 000000000..a3c2dd357 --- /dev/null +++ b/e2e/live/field-conditional-rules.spec.ts @@ -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