Files
canvas-frontend/src/scenes/Wizard.tsx
T
shad f739eedb47
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
feat(wizard): per-phase preview — graph in Analyze, form in Generate (#8)
2026-06-15 00:18:25 +00: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>
);
}