diff --git a/.changeset/flow-runner-screen-flows.md b/.changeset/flow-runner-screen-flows.md new file mode 100644 index 000000000..64bf51cbb --- /dev/null +++ b/.changeset/flow-runner-screen-flows.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': minor +--- + +Add FlowRunner — render & resume interactive screen-flows + +A `type: 'flow'` action whose run pauses at a `screen` node now opens a +`FlowRunner` modal that renders the screen's fields, submits the values to the +framework resume endpoint (`POST /api/v1/automation/{flow}/runs/{runId}/resume`), +and advances to the next screen or closes + refreshes on completion. Previously +such flows launched server-side but the screen was never rendered, so the input +was never collected. + +- New `FlowRunner` component (fields → form → resume loop). +- `ObjectView` + `RecordDetailView` flow handlers detect a paused-screen launch + response (`{ status:'paused', runId, screen }`) and open the runner; for + list_item actions the row's id (`_rowRecord.id`) flows in as the flow's + `recordId`. + +Pairs with the framework screen-flow runtime (`@objectstack/service-automation` ++ `@objectstack/runtime`). Verified in-browser: showcase task row → "Reassign…" +→ form → submit → the task is reassigned. diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx new file mode 100644 index 000000000..98f74de25 --- /dev/null +++ b/packages/app-shell/src/views/FlowRunner.tsx @@ -0,0 +1,197 @@ +/** + * FlowRunner — renders the interactive `screen` of a paused screen-flow run + * (framework screen-flow runtime, ADR-0019) and resumes it with the collected + * input. + * + * A `type: 'flow'` action launches a flow; when the run pauses at a `screen` + * node the launch response carries `{ status: 'paused', runId, screen }`. The + * host view (ObjectView / RecordDetailView) opens this modal with that state. + * On submit it POSTs `/api/v1/automation/{flow}/runs/{runId}/resume` with the + * field values as `inputs`; a `paused` response renders the next screen + * (multi-screen wizards), a terminal response closes and refreshes the view. + */ +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + Button, + Input, + Label, + Textarea, + Checkbox, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@object-ui/components'; +import { toast } from 'sonner'; + +export interface ScreenFieldSpec { + name: string; + label?: string; + type?: string; + required?: boolean; + options?: Array<{ value: unknown; label: string }>; + defaultValue?: unknown; + placeholder?: string; +} +export interface ScreenSpec { + nodeId: string; + title?: string; + description?: string; + fields: ScreenFieldSpec[]; +} +export interface ScreenFlowState { + flowName: string; + runId: string; + screen: ScreenSpec; +} + +export interface FlowRunnerProps { + /** The paused screen-flow to drive, or `null` when closed. */ + state: ScreenFlowState | null; + /** Authenticated fetch (shared with the host view). */ + authFetch: (url: string, init?: RequestInit) => Promise; + /** API base (e.g. `import.meta.env.VITE_SERVER_URL || ''`). */ + baseUrl: string; + /** User dismissed the runner without completing. */ + onClose: () => void; + /** The flow ran to completion — host should refresh. */ + onComplete: () => void; +} + +function initialValues(screen: ScreenSpec): Record { + const v: Record = {}; + for (const f of screen.fields) if (f.defaultValue !== undefined) v[f.name] = f.defaultValue; + return v; +} + +export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete }: FlowRunnerProps) { + const [screen, setScreen] = useState(null); + const [runId, setRunId] = useState(''); + const [flowName, setFlowName] = useState(''); + const [values, setValues] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (state) { + setScreen(state.screen); + setRunId(state.runId); + setFlowName(state.flowName); + setValues(initialValues(state.screen)); + } + }, [state]); + + if (!state || !screen) return null; + + const setVal = (name: string, v: unknown) => setValues((p) => ({ ...p, [name]: v })); + + const submit = async () => { + const missing = screen.fields.filter( + (f) => f.required && (values[f.name] === undefined || values[f.name] === '' || values[f.name] === null), + ); + if (missing.length) { + toast.error(`Please fill: ${missing.map((f) => f.label || f.name).join(', ')}`); + return; + } + setSubmitting(true); + try { + const res = await authFetch( + `${baseUrl}/api/v1/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/resume`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: values }) }, + ); + const json = await res.json().catch(() => null); + if (!res.ok || json?.success === false) { + toast.error(json?.error || `Resume failed (HTTP ${res.status})`); + return; + } + const data = json?.data ?? {}; + // The HTTP envelope is `{ success:true, data: AutomationResult }`; a flow + // that errored downstream surfaces as `data.success === false`. + if (data.success === false || data.status === 'failed') { + toast.error(data.error || 'The flow failed to complete.'); + return; + } + if (data.status === 'paused' && data.screen) { + setScreen(data.screen); + setRunId(data.runId || runId); + setValues(initialValues(data.screen)); + toast.success('Saved — next step'); + } else { + toast.success('Done'); + onComplete(); + } + } catch (err) { + toast.error((err as Error).message); + } finally { + setSubmitting(false); + } + }; + + return ( + { if (!o && !submitting) onClose(); }}> + + + {screen.title || 'Input'} + {screen.description && {screen.description}} + + +
+ {screen.fields.map((f) => ( +
+ + setVal(f.name, v)} /> +
+ ))} +
+ + + + + +
+
+ ); +} + +function FieldInput({ field, value, onChange }: { field: ScreenFieldSpec; value: unknown; onChange: (v: unknown) => void }) { + const id = `ff-${field.name}`; + const t = (field.type || 'text').toLowerCase(); + + if (Array.isArray(field.options) && field.options.length > 0) { + return ( + + ); + } + if (t === 'boolean' || t === 'checkbox') { + return onChange(c === true)} />; + } + if (t === 'textarea' || t === 'markdown') { + return