From f034f02b64beb38a19b9ead8e38a84af478ae33e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 04:08:57 +0000 Subject: [PATCH] fix(flow-runner): honor a screen field's visibleWhen, in render and validation (framework#3528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paused screen-flow rendered every declared field regardless of its `visibleWhen` predicate while still enforcing `required` over the full list. Where a field is optional-by-design but required *when shown*, that dead-ends the run: Submit blocks on an input the user was never shown, issues zero network requests, and the flow stays paused. Reproduced in Chromium against a live HotCRM dev server on both the console shipped with 16.1.0 and current main — trigger fires, all three lead-conversion fields render, Submit does nothing, no toast, dialog stuck open. The predicate never reached the client: the framework declared `visibleWhen` on the screen node's designer form but dropped it when building the paused payload (objectstack#3771). This is the consumer half. - `visibleScreenFields(screen, values)` becomes the single source of truth. ScreenView renders from it, FlowRunner.submit() validates from it. Splitting render from enforcement is precisely the defect, so they now share one call. - Predicates are bare CEL over the screen's own field names, evaluated by the canonical @objectstack/formula engine — the same verdict the server reaches for field rules. Values bind bare and under `record.`. - Declared fields are seeded before evaluation. An untouched checkbox holds `undefined`, which CEL reads as an unknown identifier: the evaluation errors, falls open, and leaves the dependent field visible in exactly the state where it should be hidden. Booleans seed false, everything else null. - Fail-open survives for genuinely broken predicates (syntax error, or a name that is not a field on this screen) — hiding an input on a typo would silently drop data the flow is waiting for. Both new regression assertions verified to fail with either half reverted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0122xvfanLmniNbsAPtg5hk3 --- .changeset/screen-field-visible-when.md | 42 +++++ packages/app-shell/src/views/FlowRunner.tsx | 12 +- packages/app-shell/src/views/ScreenView.tsx | 66 +++++++- .../__tests__/FlowRunner.visibleWhen.test.tsx | 158 ++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 .changeset/screen-field-visible-when.md create mode 100644 packages/app-shell/src/views/__tests__/FlowRunner.visibleWhen.test.tsx diff --git a/.changeset/screen-field-visible-when.md b/.changeset/screen-field-visible-when.md new file mode 100644 index 0000000000..7efb6b96e3 --- /dev/null +++ b/.changeset/screen-field-visible-when.md @@ -0,0 +1,42 @@ +--- +"@object-ui/app-shell": patch +--- + +fix(flow-runner): honor a screen field's `visibleWhen` — in rendering AND in required-enforcement (framework#3528) + +A paused screen-flow rendered every declared field regardless of its +`visibleWhen` predicate, while still enforcing `required` over the full list. +Where a field is optional-by-design but required *when shown*, that combination +dead-ends the run: Submit blocks on an input the user was never shown, issues +**zero network requests**, and the flow sits paused forever. + +Reproduced in Chromium against a real HotCRM dev server — on both the console +shipped with `@objectstack/*` 16.1.0 and current `main`: + +``` +→ POST /api/v1/automation/lead_conversion/trigger 200 {status: paused, screen} + rendered: ["Create Opportunity? *", "Opportunity Name *", "Opportunity Amount"] + click Submit (checkbox untouched) +→ (nothing) resume calls: 0 toasts: none dialog: still open +``` + +The predicate never reached the client — the framework declared `visibleWhen` on +the screen node's designer form but dropped it when building the paused payload +(fixed in objectstack#3771). This is the consumer half. + +- **`visibleScreenFields(screen, values)`** is the single source of truth for + what is on screen. `ScreenView` renders from it and `FlowRunner.submit()` + validates from it, so the two can never disagree — splitting them is the bug. +- Predicates are **bare CEL over the screen's own field names** + (`createOpportunity == true`), evaluated through the canonical + `@objectstack/formula` engine, the same verdict the server reaches for field + rules. Values bind both bare and under `record.`. +- **Declared fields are seeded before evaluation.** An untouched checkbox holds + `undefined`, which CEL treats as an unknown identifier — the evaluation errors + and falls open, leaving the dependent field on screen in exactly the state + where it should be hidden. Booleans seed `false`, everything else `null`. +- **Fail-open is preserved for genuinely broken predicates** (syntax error, or a + name that is not a field on this screen), matching `resolveFieldRuleState`: + hiding an input on a typo would silently drop data the flow is waiting for. + +Screens with no `visibleWhen` behave exactly as before. diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx index 920b4801ed..d4cce8ed1c 100644 --- a/packages/app-shell/src/views/FlowRunner.tsx +++ b/packages/app-shell/src/views/FlowRunner.tsx @@ -25,7 +25,7 @@ import { Button, } from '@object-ui/components'; import { toast } from 'sonner'; -import { ScreenView, isObjectFormScreen, initialScreenValues, screenFields, type ScreenSpec } from './ScreenView'; +import { ScreenView, isObjectFormScreen, initialScreenValues, visibleScreenFields, type ScreenSpec } from './ScreenView'; export type { ScreenSpec, ScreenFieldSpec } from './ScreenView'; @@ -120,7 +120,15 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat }; const submit = async () => { - const missing = screenFields(screen).filter( + // Enforce `required` over the fields ACTUALLY ON SCREEN, not the whole + // declared list. A `visibleWhen` field that is required *when shown* is not + // required while hidden — the user was never asked for it, and the flow is + // not waiting on it. Validating the full list here is what dead-ended + // #3528: HotCRM's lead conversion declares `opportunityName` required with + // `visibleWhen: createOpportunity == true`, so leaving the checkbox + // unticked blocked Submit on an input that was not on screen, and the run + // sat paused with no resume request ever issued. + const missing = visibleScreenFields(screen, values).filter( (f) => f.required && (values[f.name] === undefined || values[f.name] === '' || values[f.name] === null), ); if (missing.length) { diff --git a/packages/app-shell/src/views/ScreenView.tsx b/packages/app-shell/src/views/ScreenView.tsx index 39ba930cb4..58afcee2db 100644 --- a/packages/app-shell/src/views/ScreenView.tsx +++ b/packages/app-shell/src/views/ScreenView.tsx @@ -28,6 +28,7 @@ import { cn, } from '@object-ui/components'; import { ObjectForm } from '@object-ui/plugin-form'; +import { evalFieldPredicate } from '@object-ui/core'; export interface ScreenFieldSpec { name: string; @@ -37,6 +38,14 @@ export interface ScreenFieldSpec { options?: Array<{ value: unknown; label: string }>; defaultValue?: unknown; placeholder?: string; + /** + * Conditional-visibility predicate — bare CEL over the screen's own field + * names (`createOpportunity == true`), re-evaluated against the values + * collected so far. Omit = always visible. Read it through + * {@link visibleScreenFields}, never field-by-field, so rendering and + * `required` enforcement can never disagree (#3528). + */ + visibleWhen?: string; } export interface ScreenSpec { nodeId: string; @@ -73,6 +82,61 @@ export function screenFields(screen: ScreenSpec): ScreenFieldSpec[] { return Array.isArray(screen.fields) ? screen.fields : []; } +/** + * The fields actually on screen for the values collected so far — i.e. every + * field whose `visibleWhen` predicate holds (or that declares none). + * + * This is the list callers must use for BOTH rendering and `required` + * enforcement. Splitting them is the #3528 dead-end: validate the full list + * while rendering a subset and Submit blocks on a field the user was never + * shown, with no resume request ever issued. + * + * The predicate is bare CEL over the screen's own field names + * (`createOpportunity == true`), evaluated through the canonical + * `@objectstack/formula` engine — the same verdict the server reaches for + * field rules. Values are bound both bare and under `record.`, so either + * spelling resolves. A broken predicate **fails open** (field stays visible), + * matching `resolveFieldRuleState`: hiding an input on a typo would silently + * drop data the flow is waiting for. + */ +export function visibleScreenFields( + screen: ScreenSpec, + values: Record, +): ScreenFieldSpec[] { + const scope = screenPredicateScope(screen, values); + return screenFields(screen).filter((f) => + f.visibleWhen ? evalFieldPredicate(f.visibleWhen, scope, true, undefined, scope) : true, + ); +} + +/** + * The scope a screen's `visibleWhen` predicates evaluate against: every + * DECLARED field bound to its collected value, or to a known-empty one when + * the user has not touched it yet. + * + * Seeding matters. An untouched checkbox holds `undefined`, and to CEL an + * unbound name is an *unknown identifier* — the evaluation errors and + * `evalFieldPredicate` falls open, leaving `createOpportunity == true` fields + * on screen precisely in the state where they should be hidden. Binding the + * declared names makes the predicate resolve to a real `false` instead. + * + * Fail-open is still the behaviour we want for a genuinely broken predicate — + * a syntax error, or a name that is not a field on this screen. Seeding only + * the declared names keeps that split intact. + */ +function screenPredicateScope( + screen: ScreenSpec, + values: Record, +): Record { + const scope: Record = {}; + for (const f of screenFields(screen)) { + const t = (f.type || 'text').toLowerCase(); + scope[f.name] = t === 'boolean' || t === 'checkbox' ? false : null; + } + for (const [k, v] of Object.entries(values)) if (v !== undefined) scope[k] = v; + return scope; +} + /** Seed flat-field values from each field's `defaultValue`. */ export function initialScreenValues(screen: ScreenSpec): Record { const v: Record = {}; @@ -154,7 +218,7 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects, return (
- {screenFields(screen).map((f) => ( + {visibleScreenFields(screen, values).map((f) => (