Skip to content

Commit 1bb77aa

Browse files
authored
fix(flow-runner): honor a screen field's visibleWhen — render and validation (framework#3528) (#2899)
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. 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-ai/objectstack#3771). This is the consumer half. - `visibleScreenFields(screen, values)` is the single source of truth: ScreenView renders from it, FlowRunner.submit() validates from it. Splitting render from enforcement is precisely the defect. - 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. - Declared fields are seeded before evaluation: an untouched checkbox holds `undefined`, which CEL reads as an unknown identifier, so the evaluation errors and falls open, leaving the dependent field visible in exactly the state where it should be hidden. - Fail-open survives for genuinely broken predicates — 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.
1 parent cfc675e commit 1bb77aa

4 files changed

Lines changed: 275 additions & 3 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(flow-runner): honor a screen field's `visibleWhen` — in rendering AND in required-enforcement (framework#3528)
6+
7+
A paused screen-flow rendered every declared field regardless of its
8+
`visibleWhen` predicate, while still enforcing `required` over the full list.
9+
Where a field is optional-by-design but required *when shown*, that combination
10+
dead-ends the run: Submit blocks on an input the user was never shown, issues
11+
**zero network requests**, and the flow sits paused forever.
12+
13+
Reproduced in Chromium against a real HotCRM dev server — on both the console
14+
shipped with `@objectstack/*` 16.1.0 and current `main`:
15+
16+
```
17+
→ POST /api/v1/automation/lead_conversion/trigger 200 {status: paused, screen}
18+
rendered: ["Create Opportunity? *", "Opportunity Name *", "Opportunity Amount"]
19+
click Submit (checkbox untouched)
20+
→ (nothing) resume calls: 0 toasts: none dialog: still open
21+
```
22+
23+
The predicate never reached the client — the framework declared `visibleWhen` on
24+
the screen node's designer form but dropped it when building the paused payload
25+
(fixed in objectstack#3771). This is the consumer half.
26+
27+
- **`visibleScreenFields(screen, values)`** is the single source of truth for
28+
what is on screen. `ScreenView` renders from it and `FlowRunner.submit()`
29+
validates from it, so the two can never disagree — splitting them is the bug.
30+
- Predicates are **bare CEL over the screen's own field names**
31+
(`createOpportunity == true`), evaluated through the canonical
32+
`@objectstack/formula` engine, the same verdict the server reaches for field
33+
rules. Values bind both bare and under `record.`.
34+
- **Declared fields are seeded before evaluation.** An untouched checkbox holds
35+
`undefined`, which CEL treats as an unknown identifier — the evaluation errors
36+
and falls open, leaving the dependent field on screen in exactly the state
37+
where it should be hidden. Booleans seed `false`, everything else `null`.
38+
- **Fail-open is preserved for genuinely broken predicates** (syntax error, or a
39+
name that is not a field on this screen), matching `resolveFieldRuleState`:
40+
hiding an input on a typo would silently drop data the flow is waiting for.
41+
42+
Screens with no `visibleWhen` behave exactly as before.

packages/app-shell/src/views/FlowRunner.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
Button,
2626
} from '@object-ui/components';
2727
import { toast } from 'sonner';
28-
import { ScreenView, isObjectFormScreen, initialScreenValues, screenFields, type ScreenSpec } from './ScreenView';
28+
import { ScreenView, isObjectFormScreen, initialScreenValues, visibleScreenFields, type ScreenSpec } from './ScreenView';
2929

3030
export type { ScreenSpec, ScreenFieldSpec } from './ScreenView';
3131

@@ -120,7 +120,15 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
120120
};
121121

122122
const submit = async () => {
123-
const missing = screenFields(screen).filter(
123+
// Enforce `required` over the fields ACTUALLY ON SCREEN, not the whole
124+
// declared list. A `visibleWhen` field that is required *when shown* is not
125+
// required while hidden — the user was never asked for it, and the flow is
126+
// not waiting on it. Validating the full list here is what dead-ended
127+
// #3528: HotCRM's lead conversion declares `opportunityName` required with
128+
// `visibleWhen: createOpportunity == true`, so leaving the checkbox
129+
// unticked blocked Submit on an input that was not on screen, and the run
130+
// sat paused with no resume request ever issued.
131+
const missing = visibleScreenFields(screen, values).filter(
124132
(f) => f.required && (values[f.name] === undefined || values[f.name] === '' || values[f.name] === null),
125133
);
126134
if (missing.length) {

packages/app-shell/src/views/ScreenView.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
cn,
2929
} from '@object-ui/components';
3030
import { ObjectForm } from '@object-ui/plugin-form';
31+
import { evalFieldPredicate } from '@object-ui/core';
3132

3233
export interface ScreenFieldSpec {
3334
name: string;
@@ -37,6 +38,14 @@ export interface ScreenFieldSpec {
3738
options?: Array<{ value: unknown; label: string }>;
3839
defaultValue?: unknown;
3940
placeholder?: string;
41+
/**
42+
* Conditional-visibility predicate — bare CEL over the screen's own field
43+
* names (`createOpportunity == true`), re-evaluated against the values
44+
* collected so far. Omit = always visible. Read it through
45+
* {@link visibleScreenFields}, never field-by-field, so rendering and
46+
* `required` enforcement can never disagree (#3528).
47+
*/
48+
visibleWhen?: string;
4049
}
4150
export interface ScreenSpec {
4251
nodeId: string;
@@ -73,6 +82,61 @@ export function screenFields(screen: ScreenSpec): ScreenFieldSpec[] {
7382
return Array.isArray(screen.fields) ? screen.fields : [];
7483
}
7584

85+
/**
86+
* The fields actually on screen for the values collected so far — i.e. every
87+
* field whose `visibleWhen` predicate holds (or that declares none).
88+
*
89+
* This is the list callers must use for BOTH rendering and `required`
90+
* enforcement. Splitting them is the #3528 dead-end: validate the full list
91+
* while rendering a subset and Submit blocks on a field the user was never
92+
* shown, with no resume request ever issued.
93+
*
94+
* The predicate is bare CEL over the screen's own field names
95+
* (`createOpportunity == true`), evaluated through the canonical
96+
* `@objectstack/formula` engine — the same verdict the server reaches for
97+
* field rules. Values are bound both bare and under `record.`, so either
98+
* spelling resolves. A broken predicate **fails open** (field stays visible),
99+
* matching `resolveFieldRuleState`: hiding an input on a typo would silently
100+
* drop data the flow is waiting for.
101+
*/
102+
export function visibleScreenFields(
103+
screen: ScreenSpec,
104+
values: Record<string, unknown>,
105+
): ScreenFieldSpec[] {
106+
const scope = screenPredicateScope(screen, values);
107+
return screenFields(screen).filter((f) =>
108+
f.visibleWhen ? evalFieldPredicate(f.visibleWhen, scope, true, undefined, scope) : true,
109+
);
110+
}
111+
112+
/**
113+
* The scope a screen's `visibleWhen` predicates evaluate against: every
114+
* DECLARED field bound to its collected value, or to a known-empty one when
115+
* the user has not touched it yet.
116+
*
117+
* Seeding matters. An untouched checkbox holds `undefined`, and to CEL an
118+
* unbound name is an *unknown identifier* — the evaluation errors and
119+
* `evalFieldPredicate` falls open, leaving `createOpportunity == true` fields
120+
* on screen precisely in the state where they should be hidden. Binding the
121+
* declared names makes the predicate resolve to a real `false` instead.
122+
*
123+
* Fail-open is still the behaviour we want for a genuinely broken predicate —
124+
* a syntax error, or a name that is not a field on this screen. Seeding only
125+
* the declared names keeps that split intact.
126+
*/
127+
function screenPredicateScope(
128+
screen: ScreenSpec,
129+
values: Record<string, unknown>,
130+
): Record<string, unknown> {
131+
const scope: Record<string, unknown> = {};
132+
for (const f of screenFields(screen)) {
133+
const t = (f.type || 'text').toLowerCase();
134+
scope[f.name] = t === 'boolean' || t === 'checkbox' ? false : null;
135+
}
136+
for (const [k, v] of Object.entries(values)) if (v !== undefined) scope[k] = v;
137+
return scope;
138+
}
139+
76140
/** Seed flat-field values from each field's `defaultValue`. */
77141
export function initialScreenValues(screen: ScreenSpec): Record<string, unknown> {
78142
const v: Record<string, unknown> = {};
@@ -154,7 +218,7 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects,
154218

155219
return (
156220
<div className={cn('space-y-4 py-2', className)}>
157-
{screenFields(screen).map((f) => (
221+
{visibleScreenFields(screen, values).map((f) => (
158222
<div key={f.name} className="space-y-1.5">
159223
<Label htmlFor={`ff-${f.name}`} className="text-sm">
160224
{f.label || f.name}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `visibleWhen` on a screen field — rendering AND `required` enforcement.
5+
*
6+
* These must agree. #3528: the framework declared `visibleWhen` on the screen
7+
* node's designer form but never put it on the wire, so every field rendered
8+
* unconditionally while `required` was still enforced. HotCRM's lead conversion
9+
* is the shape that made that fatal — an `opportunityName` that is required
10+
* only *when shown*. Leave the checkbox unticked and Submit blocked on an input
11+
* the user was never shown, issuing zero network requests, with the run left
12+
* paused forever.
13+
*
14+
* The server half now forwards the predicate (objectstack#3771). This is the
15+
* client half: honour it in both places, from one helper, so they cannot drift.
16+
*/
17+
import { describe, it, expect, vi, beforeEach } from 'vitest';
18+
import { render, screen, waitFor } from '@testing-library/react';
19+
import userEvent from '@testing-library/user-event';
20+
import { FlowRunner, type ScreenFlowState } from '../FlowRunner';
21+
22+
/** HotCRM's `lead_conversion` screen, verbatim in shape. */
23+
const CONVERSION: ScreenFlowState = {
24+
flowName: 'lead_conversion',
25+
runId: 'run-1',
26+
screen: {
27+
nodeId: 'screen_1',
28+
title: 'Conversion Details',
29+
fields: [
30+
{ name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', required: true },
31+
{
32+
name: 'opportunityName',
33+
label: 'Opportunity Name',
34+
type: 'text',
35+
required: true,
36+
visibleWhen: 'createOpportunity == true',
37+
},
38+
{
39+
name: 'opportunityAmount',
40+
label: 'Opportunity Amount',
41+
type: 'currency',
42+
visibleWhen: 'createOpportunity == true',
43+
},
44+
],
45+
},
46+
};
47+
48+
function jsonResponse(body: unknown, status = 200): Response {
49+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
50+
}
51+
52+
function setup(state: ScreenFlowState, authFetch: (url: string, init?: RequestInit) => Promise<Response>) {
53+
const onClose = vi.fn();
54+
const onComplete = vi.fn();
55+
render(<FlowRunner state={state} authFetch={authFetch} baseUrl="" onClose={onClose} onComplete={onComplete} />);
56+
return { onClose, onComplete };
57+
}
58+
59+
const okResume = () => vi.fn(async () => jsonResponse({ success: true, data: { success: true } }));
60+
61+
beforeEach(() => vi.restoreAllMocks());
62+
63+
describe('screen field visibleWhen (#3528)', () => {
64+
it('hides a field whose predicate is false', () => {
65+
setup(CONVERSION, okResume());
66+
expect(screen.getByText('Create Opportunity?')).toBeInTheDocument();
67+
expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument();
68+
expect(screen.queryByText('Opportunity Amount')).not.toBeInTheDocument();
69+
});
70+
71+
it('reveals the dependent fields once the predicate holds', async () => {
72+
const user = userEvent.setup();
73+
setup(CONVERSION, okResume());
74+
await user.click(screen.getByRole('checkbox'));
75+
await waitFor(() => expect(screen.getByText('Opportunity Name')).toBeInTheDocument());
76+
expect(screen.getByText('Opportunity Amount')).toBeInTheDocument();
77+
});
78+
79+
/**
80+
* The regression. A hidden `required` field must not block Submit — the whole
81+
* reported defect is that it did, silently.
82+
*/
83+
it('RESUMES with a hidden required field left empty', async () => {
84+
const user = userEvent.setup();
85+
const authFetch = okResume();
86+
const { onComplete } = setup(CONVERSION, authFetch);
87+
88+
// Tick the required boolean; `opportunityName` stays hidden and empty.
89+
await user.click(screen.getByRole('checkbox'));
90+
await waitFor(() => expect(screen.getByText('Opportunity Name')).toBeInTheDocument());
91+
await user.click(screen.getByRole('checkbox')); // untick → hidden again
92+
await waitFor(() => expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument());
93+
94+
await user.click(screen.getByRole('button', { name: 'Submit' }));
95+
96+
await waitFor(() => expect(authFetch).toHaveBeenCalledTimes(1));
97+
expect(authFetch.mock.calls[0][0]).toBe('/api/v1/automation/lead_conversion/runs/run-1/resume');
98+
expect(onComplete).toHaveBeenCalled();
99+
});
100+
101+
it('still blocks Submit on a VISIBLE required field left empty', async () => {
102+
const user = userEvent.setup();
103+
const authFetch = okResume();
104+
setup(CONVERSION, authFetch);
105+
106+
// Reveal `opportunityName` (required) and submit without filling it.
107+
await user.click(screen.getByRole('checkbox'));
108+
await waitFor(() => expect(screen.getByText('Opportunity Name')).toBeInTheDocument());
109+
await user.click(screen.getByRole('button', { name: 'Submit' }));
110+
111+
// No resume — the field is genuinely on screen and genuinely required.
112+
await new Promise((r) => setTimeout(r, 50));
113+
expect(authFetch).not.toHaveBeenCalled();
114+
});
115+
116+
/**
117+
* Fail-open, matching `resolveFieldRuleState`: hiding an input because its
118+
* predicate has a typo would silently drop data the flow is waiting for.
119+
*/
120+
it('keeps a field visible when its predicate is broken', () => {
121+
setup(
122+
{
123+
flowName: 'f',
124+
runId: 'r',
125+
screen: {
126+
nodeId: 'n',
127+
title: 'Broken',
128+
fields: [{ name: 'note', label: 'Note', type: 'text', visibleWhen: 'this is not CEL (((' }],
129+
},
130+
},
131+
okResume(),
132+
);
133+
expect(screen.getByText('Note')).toBeInTheDocument();
134+
});
135+
136+
it('leaves a screen with no predicates completely unchanged', async () => {
137+
const user = userEvent.setup();
138+
const authFetch = okResume();
139+
setup(
140+
{
141+
flowName: 'reassign',
142+
runId: 'run-9',
143+
screen: {
144+
nodeId: 'collect',
145+
title: 'New Assignee',
146+
fields: [{ name: 'new_assignee', label: 'Assignee', type: 'text', required: true }],
147+
},
148+
},
149+
authFetch,
150+
);
151+
await user.type(screen.getAllByRole('textbox')[0], 'ada@example.com');
152+
await user.click(screen.getByRole('button', { name: 'Submit' }));
153+
await waitFor(() => expect(authFetch).toHaveBeenCalledTimes(1));
154+
expect(JSON.parse(String(authFetch.mock.calls[0][1]?.body))).toMatchObject({
155+
inputs: { new_assignee: 'ada@example.com' },
156+
});
157+
});
158+
});

0 commit comments

Comments
 (0)