Files
canvas-frontend/src/scenes/Wizard.tsx
T
canvas-bot 0a57bbc464
build-and-publish / test (pull_request) Has been cancelled
build-and-publish / image (pull_request) Has been cancelled
feat(wizard): per-phase preview — graph in Analyze, form in Generate
The Wizard already has the progress stepper with checkmarks, the
preview pane, and the review summary card before Publish. The
remaining gap from the UX overhaul was that the preview pane showed
the same flat list regardless of phase. Specialise it:

- Analyze + onward: graph-style vertical chain with dispatch-kind
  bubbles (manual / assistant / system) and connector edges, so the
  operator sees the flow as a graph not a list.
- Generate + onward: form preview that renders disabled input/select
  controls for each named field, so the operator sees what end users
  will fill in.
- Validate + onward: rules list (unchanged).
- Draft saved: all three render together so the review card has the
  full picture beside it.

EA2 write contract is unchanged — createDraft / applyBatch /
publishFlow still fire at every phase transition. 31/31 vitest pass.
2026-06-15 04:18:04 +04:00

575 lines
24 KiB
TypeScript

import { useState, useEffect } from "react";
import { useApp } from "../state/store";
import { wizardApi, type BatchOperation } from "../lib/wizardApi";
import { Branch, Check, Close, ChevronRight } from "../components/icons";
type WizardStep = "Intake" | "Analyze" | "Generate" | "Validate" | "Draft saved" | "Publish";
const STEPS: WizardStep[] = ["Intake", "Analyze", "Generate", "Validate", "Draft saved", "Publish"];
interface DraftState {
step: WizardStep;
flowKey?: string;
name: string;
description: string;
nodes: any[];
edges: any[];
fields: { name: string; type: string }[];
rules: string[];
}
export default function Wizard() {
const mode = useApp((s) => s.mode);
const pushToast = useApp((s) => s.pushToast);
const refreshLive = useApp((s) => s.refreshLive);
const actor = useApp((s) => s.actor);
const setScene = useApp((s) => s.setScene);
const canPublish = mode === "live" && !!actor?.user_id;
const [draft, setDraft] = useState<DraftState>(() => {
try {
const stored = localStorage.getItem("fm.mc.wizard.draft");
if (stored) return JSON.parse(stored);
} catch {}
return {
step: "Intake",
name: "",
description: "",
nodes: [],
edges: [],
fields: [],
rules: []
};
});
const [working, setWorking] = useState(false);
const [publishedKey, setPublishedKey] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem("fm.mc.wizard.draft", JSON.stringify(draft));
}, [draft]);
const updateDraft = (patch: Partial<DraftState>) => {
setDraft(d => ({ ...d, ...patch }));
};
const handleIntakeSubmit = async () => {
if (!draft.description) return;
setWorking(true);
try {
const slug = `process_${Date.now()}`;
const res = await wizardApi.createDraft({
name: slug,
display_name: draft.name || "Untitled Process",
description: draft.description,
source_context: "EA2_DRAFT_PROCESS:process_creation",
config: {
wizard: {
marker: "EA2_DRAFT_PROCESS",
maxDepth: 5,
maxNodes: 40,
chat: [],
uploads: [],
panelState: {},
debug: []
}
}
});
// Auto-generate basic 5-step template
const templateNodes = [
{ _key: "n1", kind: "step", display_name: "Capture request details", dispatch_kind: "human" },
{ _key: "n2", kind: "step", display_name: "Validate requirements", dispatch_kind: "agent", agent_capability: "evaluate_business_rule" },
{ _key: "n3", kind: "step", display_name: "Prepare work package", dispatch_kind: "agent", agent_capability: "process_design_assistant" },
{ _key: "n4", kind: "step", display_name: "Review and approve", dispatch_kind: "human" },
{ _key: "n5", kind: "step", display_name: "Close out process", dispatch_kind: "human" }
];
updateDraft({
flowKey: res._key,
step: "Analyze",
nodes: templateNodes
});
pushToast("ok", "Draft created in EA2");
} catch (err: any) {
pushToast("err", `Failed to create draft: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStructureSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const stepCreateOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createStep({
display_name: n.display_name,
dispatch_kind: n.dispatch_kind,
agent_capability: n.agent_capability,
})
);
const viewCreateOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createView({ display_name: n.display_name })
);
const versionCreateOp: BatchOperation = wizardApi.ops.createVersion(draft.name || "Process");
const allCreateOps = [...stepCreateOps, ...viewCreateOps, versionCreateOp];
const createRes = await wizardApi.applyBatch(draft.flowKey, allCreateOps, actor);
const createdOps: any[] = createRes?.result?.ops || [];
const stepKeys = createdOps.slice(0, draft.nodes.length).map(o => o.key);
const viewKeys = createdOps.slice(draft.nodes.length, draft.nodes.length * 2).map(o => o.key);
const versionKey = createdOps[createdOps.length - 1]?.key;
const nodesWithKeys = draft.nodes.map((n, i) => ({ ...n, _key: stepKeys[i] || n._key, viewKey: viewKeys[i] }));
const wireOps: BatchOperation[] = [
...nodesWithKeys.map((n, _i) => wizardApi.ops.childEdge(draft.flowKey!, n._key)),
...nodesWithKeys.map(n => wizardApi.ops.dispatchEdge(n._key)),
...nodesWithKeys.map(n => wizardApi.ops.presentationEdge(n._key, n.viewKey)),
];
if (versionKey) wireOps.push(wizardApi.ops.governsEdge(versionKey, draft.flowKey!));
if (wireOps.length) await wizardApi.applyBatch(draft.flowKey, wireOps, actor);
updateDraft({ step: "Generate", nodes: nodesWithKeys });
pushToast("ok", `Saved ${stepKeys.length} steps with views and version to EA2`);
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleDataSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { fields: draft.fields } } }
});
if (draft.fields.length > 0 && draft.nodes.length > 0) {
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const oldEdgeKeys: string[] = [];
for (const n of draft.nodes) {
try {
const r = await fetch(
`${(await import("../lib/api")).api.config.baseUrl}/api/ea2/edges/defines?from=flow/${n._key}&limit=50`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!r.ok) continue;
const items = ((await r.json())?.items || []) as any[];
for (const e of items) {
if (e?.role === "presentation" && e?._key) oldEdgeKeys.push(e._key);
}
} catch { /* ignore */ }
}
const replaceOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createView({ display_name: n.display_name, fields: draft.fields })
);
const res = await wizardApi.applyBatch(draft.flowKey, replaceOps, actor);
const newViewKeys = (res?.result?.ops || []).map((o: any) => o.key);
const wireOps: BatchOperation[] = [
...oldEdgeKeys.map((k): BatchOperation => ({ op: "delete_edge", edge_coll: "defines", key: k })),
...draft.nodes.map((n, i) => wizardApi.ops.presentationEdge(n._key, newViewKeys[i])),
];
if (wireOps.length) await wizardApi.applyBatch(draft.flowKey, wireOps, actor);
updateDraft({ nodes: draft.nodes.map((n, i) => ({ ...n, viewKey: newViewKeys[i] })) });
}
updateDraft({ step: "Validate" });
pushToast("ok", `Saved ${draft.fields.length} fields onto every step's form`);
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleRulesSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { rules: draft.rules } } }
});
updateDraft({ step: "Draft saved" });
pushToast("ok", "Rules saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handlePublish = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const ops: BatchOperation[] = [wizardApi.ops.publishFlow(draft.flowKey)];
await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Publish" });
setPublishedKey(draft.flowKey);
pushToast("ok", "Process published successfully!");
refreshLive();
} catch (err: any) {
pushToast("err", `Publish failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStartInstance = async () => {
if(!publishedKey) return;
setWorking(true);
try {
await wizardApi.startInstance(publishedKey);
pushToast("ok", "New instance started!");
setScene("mission");
} catch(err:any) {
pushToast("err", `Failed to start: ${err.message}`);
} finally {
setWorking(false);
}
}
const STEP_LABELS: Record<WizardStep, string> = {
Intake: "Describe",
Analyze: "Steps",
Generate: "Form fields",
Validate: "Rules",
"Draft saved": "Review",
Publish: "Done",
};
const renderStepNav = () => (
<div className="wizard-progress">
{STEPS.map((step, i) => {
const isActive = step === draft.step;
const isPast = STEPS.indexOf(step) < STEPS.indexOf(draft.step);
const isDone = isPast || draft.step === "Publish";
return (
<div key={step} className={`wizard-step-marker ${isActive ? "active" : ""} ${isPast ? "past" : ""}`}>
<span className={`step-num ${isDone ? "step-num-done" : ""}`}>
{isDone ? <Check size={11} /> : i + 1}
</span>
<span className="step-label">{STEP_LABELS[step]}</span>
{i < STEPS.length - 1 && <ChevronRight size={12} className="step-chevron" />}
</div>
);
})}
</div>
);
const renderGraphPreview = () => (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Flow · {draft.nodes.length} step{draft.nodes.length === 1 ? "" : "s"}</div>
<div className="wizard-preview-graph">
{draft.nodes.map((n, i) => (
<div key={n._key || i} className="wizard-graph-node">
<div className={`wizard-graph-bubble kind-${n.dispatch_kind || "human"}`}>
<span className="wizard-graph-idx">{i + 1}</span>
<span className="wizard-graph-name">{n.display_name || "Untitled"}</span>
</div>
<div className="wizard-graph-meta">
{({ human: "Manual", agent: "Assistant", system: "System" } as Record<string, string>)[n.dispatch_kind || "human"]}
</div>
{i < draft.nodes.length - 1 && <div className="wizard-graph-edge" aria-hidden />}
</div>
))}
</div>
</div>
);
const renderFormPreview = () => (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Form preview · what users will fill in</div>
<div className="wizard-form-preview">
{draft.fields.filter((f) => f.name).length === 0 ? (
<div className="wizard-preview-empty">Add fields on the left to see the form here.</div>
) : (
draft.fields.filter((f) => f.name).map((f, i) => (
<div key={i} className="wizard-form-field">
<label className="wizard-form-label">{f.name}</label>
{f.type === "boolean" ? (
<select className="wizard-form-input" disabled defaultValue=""><option value="">Yes / No</option></select>
) : f.type === "number" ? (
<input className="wizard-form-input" type="number" placeholder="0" disabled />
) : (
<input className="wizard-form-input" type="text" placeholder={`Enter ${f.name.toLowerCase()}`} disabled />
)}
</div>
))
)}
</div>
</div>
);
const renderRulesPreview = () => (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Rules · {draft.rules.filter((r) => r.trim()).length}</div>
<ul className="wizard-preview-rules">
{draft.rules.filter((r) => r.trim()).map((r, i) => <li key={i}>{r}</li>)}
{draft.rules.filter((r) => r.trim()).length === 0 && (
<li className="wizard-preview-empty">No rules typed yet</li>
)}
</ul>
</div>
);
const renderPreviewPane = () => {
if (draft.nodes.length === 0 && draft.fields.length === 0 && draft.rules.length === 0) return null;
return (
<aside className="wizard-preview">
<div className="wizard-preview-head">
<span className="mc-hero-eyebrow">Live preview</span>
<div className="wizard-preview-title">{draft.name || "Untitled process"}</div>
{draft.description && <div className="wizard-preview-desc">{draft.description.slice(0, 140)}</div>}
</div>
{(draft.step === "Analyze" || draft.step === "Generate" || draft.step === "Validate" || draft.step === "Draft saved") && draft.nodes.length > 0 && renderGraphPreview()}
{(draft.step === "Generate" || draft.step === "Validate" || draft.step === "Draft saved") && renderFormPreview()}
{(draft.step === "Validate" || draft.step === "Draft saved") && renderRulesPreview()}
</aside>
);
};
const moveStep = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= draft.nodes.length) return;
const next = [...draft.nodes];
[next[i], next[j]] = [next[j], next[i]];
updateDraft({ nodes: next });
};
const removeStep = (i: number) => {
if (draft.nodes.length <= 1) {
pushToast("err", "A process needs at least one step.");
return;
}
updateDraft({ nodes: draft.nodes.filter((_, idx) => idx !== i) });
};
const addStep = () => {
const next = [...draft.nodes, { _key: `n${Date.now()}`, kind: "step", display_name: "New step", dispatch_kind: "human" }];
updateDraft({ nodes: next });
};
return (
<div className="wizard-container">
<header className="wizard-header">
<div>
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Creation Wizard</div>
<h2 className="mc-hero-title">Build a new process</h2>
</div>
{renderStepNav()}
</header>
<div className="wizard-body">
<div className="wizard-content">
{draft.step === "Intake" && (
<div className="wizard-panel">
<h3 className="panel-h">What process do you want to build?</h3>
<div className="field-group">
<label>Name (optional)</label>
<input
type="text"
value={draft.name}
onChange={e => updateDraft({ name: e.target.value })}
placeholder="e.g. Laptop Procurement"
/>
</div>
<div className="field-group">
<label>Describe the process</label>
<textarea
value={draft.description}
onChange={e => updateDraft({ description: e.target.value })}
placeholder="When a store manager requests a new laptop, run a procurement approval..."
rows={4}
/>
</div>
<button
className="btn btn-primary"
onClick={handleIntakeSubmit}
disabled={!draft.description || working || !canPublish}
>
{working ? "Saving..." : "Start drafting →"}
</button>
{!canPublish && <p className="warning-text">Switch to live mode + sign in to create processes.</p>}
</div>
)}
{draft.step === "Analyze" && (
<div className="wizard-panel">
<h3 className="panel-h">What steps does it have?</h3>
<p className="panel-sub">Reorder, rename, or pick who does each step. Order is preserved when we write to EA2.</p>
<div className="node-list">
{draft.nodes.map((n, i) => (
<div key={n._key} className="node-card">
<div className="node-header">
<span className="node-idx">{i + 1}</span>
<input
value={n.display_name}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].display_name = e.target.value;
updateDraft({ nodes: newNodes });
}}
/>
</div>
<div className="node-meta">
<select
value={n.dispatch_kind}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].dispatch_kind = e.target.value;
updateDraft({ nodes: newNodes });
}}
>
<option value="human">Manual review</option>
<option value="agent">Assistant</option>
<option value="system">System</option>
</select>
<div className="node-controls">
<button className="icon-btn" onClick={() => moveStep(i, -1)} disabled={i === 0} title="Move up"></button>
<button className="icon-btn" onClick={() => moveStep(i, 1)} disabled={i === draft.nodes.length - 1} title="Move down"></button>
<button className="icon-btn" onClick={() => removeStep(i)} title="Remove"><Close size={12} /></button>
</div>
</div>
</div>
))}
<button className="btn btn-secondary" onClick={addStep}>+ Add step</button>
</div>
<button className="btn btn-primary" onClick={handleStructureSave} disabled={working}>
{working ? "Saving..." : "Confirm structure →"}
</button>
</div>
)}
{draft.step === "Generate" && (
<div className="wizard-panel">
<h3 className="panel-h">What information do you need to collect?</h3>
<p className="panel-sub">These become the form every step asks for. You can add as many as you like.</p>
<div className="field-list">
{draft.fields.map((f, i) => (
<div key={i} className="field-row">
<input
placeholder="e.g. requester name"
value={f.name}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].name = e.target.value;
updateDraft({ fields: newFields });
}}
/>
<select
value={f.type}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].type = e.target.value;
updateDraft({ fields: newFields });
}}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">Yes / No</option>
</select>
<button className="icon-btn" onClick={() => updateDraft({ fields: draft.fields.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ fields: [...draft.fields, { name: "", type: "string" }] })}>+ Add field</button>
</div>
<button className="btn btn-primary" onClick={handleDataSave} disabled={working}>
{working ? "Saving..." : "Confirm fields →"}
</button>
</div>
)}
{draft.step === "Validate" && (
<div className="wizard-panel">
<h3 className="panel-h">Anything special the process should check?</h3>
<p className="panel-sub">Plain-English rules. We'll attach them as exception checks once published.</p>
<div className="field-list">
{draft.rules.map((r, i) => (
<div key={i} className="field-row">
<input
placeholder="e.g. If cost is over 5000, require VP approval"
value={r}
onChange={e => {
const newRules = [...draft.rules];
newRules[i] = e.target.value;
updateDraft({ rules: newRules });
}}
style={{ flex: 1 }}
/>
<button className="icon-btn" onClick={() => updateDraft({ rules: draft.rules.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ rules: [...draft.rules, ""] })}>+ Add rule</button>
</div>
<button className="btn btn-primary" onClick={handleRulesSave} disabled={working}>
{working ? "Saving..." : "Review process →"}
</button>
</div>
)}
{draft.step === "Draft saved" && (
<div className="wizard-panel">
<h3 className="panel-h">Review &amp; publish</h3>
<p className="panel-sub">Last look. Hit publish to write the definition + steps + form + version to EA2 in one atomic batch.</p>
<div className="wizard-review">
<div className="wizard-review-row">
<span className="wizard-review-label">Process</span>
<strong>{draft.name || "Untitled"}</strong>
</div>
{draft.description && (
<div className="wizard-review-row">
<span className="wizard-review-label">Purpose</span>
<span>{draft.description}</span>
</div>
)}
<div className="wizard-review-row">
<span className="wizard-review-label">Steps</span>
<span>{draft.nodes.length} ({draft.nodes.map((n) => n.display_name || "Untitled").join(" → ")})</span>
</div>
<div className="wizard-review-row">
<span className="wizard-review-label">Form fields</span>
<span>{draft.fields.filter((f) => f.name).length || "none"}</span>
</div>
<div className="wizard-review-row">
<span className="wizard-review-label">Rules</span>
<span>{draft.rules.filter((r) => r.trim()).length || "none"}</span>
</div>
</div>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Back to edit</button>
<button className="btn btn-primary" onClick={handlePublish} disabled={working}>
{working ? "Publishing..." : "Publish to EA2"}
</button>
</div>
</div>
)}
{draft.step === "Publish" && (
<div className="wizard-panel success-panel">
<div className="success-icon"><Check size={32} /></div>
<h3 className="panel-h">Process published</h3>
<p>Your process <strong>{draft.name || "Untitled"}</strong> is now active and ready to run.</p>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create another</button>
<button className="btn btn-primary" onClick={handleStartInstance} disabled={working}>
Start first instance
</button>
</div>
</div>
)}
</div>
{renderPreviewPane()}
</div>
</div>
);
}