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
22 changes: 22 additions & 0 deletions .changeset/flow-runner-screen-flows.md
Original file line number Diff line number Diff line change
@@ -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.
197 changes: 197 additions & 0 deletions packages/app-shell/src/views/FlowRunner.tsx
Original file line number Diff line number Diff line change
@@ -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<Response>;
/** 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<string, unknown> {
const v: Record<string, unknown> = {};
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<ScreenSpec | null>(null);
const [runId, setRunId] = useState('');
const [flowName, setFlowName] = useState('');
const [values, setValues] = useState<Record<string, unknown>>({});
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 (
<Dialog open onOpenChange={(o) => { if (!o && !submitting) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{screen.title || 'Input'}</DialogTitle>
{screen.description && <DialogDescription>{screen.description}</DialogDescription>}
</DialogHeader>

<div className="space-y-4 py-2">
{screen.fields.map((f) => (
<div key={f.name} className="space-y-1.5">
<Label htmlFor={`ff-${f.name}`} className="text-sm">
{f.label || f.name}
{f.required && <span className="text-destructive"> *</span>}
</Label>
<FieldInput field={f} value={values[f.name]} onChange={(v) => setVal(f.name, v)} />
</div>
))}
</div>

<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>{submitting ? 'Submitting…' : 'Submit'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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 (
<Select value={value != null ? String(value) : undefined} onValueChange={(v) => onChange(v)}>
<SelectTrigger id={id}><SelectValue placeholder={field.placeholder || 'Select…'} /></SelectTrigger>
<SelectContent>
{field.options.map((o, i) => (
<SelectItem key={i} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (t === 'boolean' || t === 'checkbox') {
return <Checkbox id={id} checked={value === true} onCheckedChange={(c) => onChange(c === true)} />;
}
if (t === 'textarea' || t === 'markdown') {
return <Textarea id={id} value={(value as string) ?? ''} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)} />;
}
const htmlType = t === 'number' || t === 'currency' ? 'number' : t === 'email' ? 'email' : t === 'date' ? 'date' : 'text';
return (
<Input
id={id}
type={htmlType}
value={(value as string) ?? ''}
placeholder={field.placeholder}
onChange={(e) => onChange(htmlType === 'number' ? (e.target.value === '' ? undefined : Number(e.target.value)) : e.target.value)}
/>
);
}
29 changes: 27 additions & 2 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { toast } from 'sonner';
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog';
import { FlowRunner, type ScreenFlowState } from './FlowRunner';
import { resolveActionParams } from '../utils/resolveActionParams';
import type { ActionDef, ActionParamDef } from '@object-ui/core';

Expand Down Expand Up @@ -386,6 +387,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }:

// Refresh trigger — bumped after view CRUD or external data mutations.
const [refreshKey, setRefreshKey] = useState(0);
// Screen-flow runtime: a paused `screen`-node flow awaiting user input.
const [screenFlow, setScreenFlow] = useState<ScreenFlowState | null>(null);

// Resolve which generic CRUD affordances belong in the toolbar for
// this object's lifecycle bucket (`managedBy`). config tables show
Expand Down Expand Up @@ -1200,14 +1203,21 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }:
}
try {
const baseUrl = import.meta.env.VITE_SERVER_URL || '';
const params = action.params || {};
// Row context is stashed on params under `_rowRecord` by the row-action
// dispatcher (ObjectGrid → onRowAction). For a list_item flow action the
// row's id is the flow's `recordId` (e.g. the Reassign wizard targets it).
const params = { ...(action.params || {}) } as Record<string, any>;
const rowRecord = params._rowRecord as Record<string, any> | undefined;
delete params._rowRecord;
const recordId = params.recordId ?? rowRecord?.id;
if (recordId != null && params.recordId == null) params.recordId = recordId;
const res = await authFetch(
`${baseUrl}/api/v1/automation/${encodeURIComponent(flowName)}/trigger`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recordId: params.recordId,
recordId,
objectName: objectDef.name,
params,
}),
Expand All @@ -1218,6 +1228,14 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }:
const errMsg = json?.error || `Flow "${flowName}" failed (HTTP ${res.status})`;
return { success: false, error: errMsg };
}
// Screen-flow runtime: the run paused at a `screen` node awaiting
// input — open the FlowRunner to render the form + resume. Refresh
// happens when the FlowRunner completes, not now.
const data = json?.data ?? {};
if (data.status === 'paused' && data.screen) {
setScreenFlow({ flowName, runId: data.runId, screen: data.screen });
return { success: true };
}
const shouldRefresh = action.refreshAfter !== false;
if (shouldRefresh) {
setRefreshKey(k => k + 1);
Expand Down Expand Up @@ -2148,6 +2166,13 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }:
setResultDialogState({ open: false });
}}
/>
<FlowRunner
state={screenFlow}
authFetch={authFetch}
baseUrl={import.meta.env.VITE_SERVER_URL || ''}
onClose={() => setScreenFlow(null)}
onComplete={() => { setScreenFlow(null); setRefreshKey(k => k + 1); }}
/>
</ActionProvider>
);
}
24 changes: 24 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { hasExplicitDiscussion } from '../utils/pageSchemaIntrospect';
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog';
import { FlowRunner, type ScreenFlowState } from './FlowRunner';
import { resolveActionParams } from '../utils/resolveActionParams';
import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
import type { DetailViewSchema, FeedItem, HighlightField, SectionGroup } from '@object-ui/types';
Expand Down Expand Up @@ -125,6 +126,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
Array<{ id: string; label: string; avatarUrl?: string }>
>([]);
const [actionRefreshKey, setActionRefreshKey] = useState(0);
// Screen-flow runtime: a paused `screen`-node flow launched from a record action.
const [screenFlow, setScreenFlow] = useState<ScreenFlowState | null>(null);
const [childRelatedData, setChildRelatedData] = useState<Record<string, any[]>>({});
const [historyEntries, setHistoryEntries] = useState<any[] | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
Expand Down Expand Up @@ -402,6 +405,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const errMsg = json?.error || `Flow "${flowName}" failed (HTTP ${res.status})`;
return { success: false, error: errMsg };
}
// Screen-flow runtime: the run paused at a `screen` node awaiting input —
// open the FlowRunner to render the form + resume (refresh on completion).
const data = json?.data ?? {};
if (data.status === 'paused' && data.screen) {
setScreenFlow({ flowName, runId: data.runId, screen: data.screen });
return { success: true };
}
const shouldRefresh = action.refreshAfter !== false;
if (shouldRefresh) {
setActionRefreshKey(k => k + 1);
Expand Down Expand Up @@ -1690,6 +1700,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
setResultDialogState({ open: false });
}}
/>
<FlowRunner
state={screenFlow}
authFetch={authFetch}
baseUrl={import.meta.env.VITE_SERVER_URL || ''}
onClose={() => setScreenFlow(null)}
onComplete={() => { setScreenFlow(null); setActionRefreshKey(k => k + 1); }}
/>
</div>
);
}
Expand Down Expand Up @@ -1790,6 +1807,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
setResultDialogState({ open: false });
}}
/>
<FlowRunner
state={screenFlow}
authFetch={authFetch}
baseUrl={import.meta.env.VITE_SERVER_URL || ''}
onClose={() => setScreenFlow(null)}
onComplete={() => { setScreenFlow(null); setActionRefreshKey(k => k + 1); }}
/>
</div>
);
}
Loading