feat: rebuild Process Studio into multi-phase Process Creation Wizard\n\n- Replace toy Studio.tsx with proper Wizard.tsx canvas\n- Build 6-step flow: Intake -> Analyze -> Generate -> Validate -> Draft saved -> Publish\n- Implement incremental EA2 writes via wizardApi.ts (flow, batch apply)\n- Use localStorage for draft recovery across reloads\n- Apply industrial-blueprint UI styling matching core doctrine\n- Add Vitest tests for Wizard components and API wrappers
This commit is contained in:
+21
-4
@@ -4,12 +4,14 @@ import { useApp, scenarioById } from "./state/store";
|
||||
import Landing from "./scenes/Landing";
|
||||
import MissionControl from "./scenes/MissionControl";
|
||||
import RunHistory from "./scenes/RunHistory";
|
||||
import Studio from "./scenes/Studio";
|
||||
import Wizard from "./scenes/Wizard";
|
||||
import Settings from "./scenes/Settings";
|
||||
import Login from "./scenes/Login";
|
||||
import SsoCallback from "./scenes/SsoCallback";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Toaster from "./components/Toaster";
|
||||
import Console from "./components/Console";
|
||||
import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons";
|
||||
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";
|
||||
import { liveMeta } from "./data/scenarios";
|
||||
|
||||
export default function App() {
|
||||
@@ -30,6 +32,16 @@ export default function App() {
|
||||
const userEmail = useApp((s) => s.userEmail);
|
||||
const startPolling = useApp((s) => s.startPolling);
|
||||
const stopPolling = useApp((s) => s.stopPolling);
|
||||
const isAuthed = useApp((s) => s.isAuthed);
|
||||
|
||||
// Auth guard and SSO callback detector
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.location.hash.includes("access_token")) {
|
||||
setScene("sso-callback");
|
||||
} else if (!isAuthed && scene !== "login" && scene !== "sso-callback") {
|
||||
setScene("login");
|
||||
}
|
||||
}, [isAuthed, scene, setScene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "live") startPolling();
|
||||
@@ -40,7 +52,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className={`shell shell-${scene}`}>
|
||||
{scene !== "landing" && (
|
||||
{scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
|
||||
<header className="topbar" data-anchor="topbar">
|
||||
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
|
||||
<span className="brand-mark sm" />
|
||||
@@ -121,6 +133,9 @@ export default function App() {
|
||||
<Layers size={12} /> Console
|
||||
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
|
||||
</button>
|
||||
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">
|
||||
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
|
||||
</button>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<Cmd size={13} /> <kbd>⌘K</kbd>
|
||||
</button>
|
||||
@@ -129,10 +144,12 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<div className="scene">
|
||||
{scene === "login" && <Login />}
|
||||
{scene === "sso-callback" && <SsoCallback />}
|
||||
{scene === "landing" && <Landing />}
|
||||
{scene === "mission" && <MissionControl />}
|
||||
{scene === "history" && <RunHistory />}
|
||||
{scene === "studio" && <Studio />}
|
||||
{scene === "studio" && <Wizard />}
|
||||
{scene === "settings" && <Settings />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Play, Branch, Bot, Check, Home, History as HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
|
||||
import { Play, Branch, Bot, Check, Home, HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
|
||||
|
||||
export default function CommandBar() {
|
||||
const open = useApp((s) => s.cmdOpen);
|
||||
@@ -80,7 +80,7 @@ export default function CommandBar() {
|
||||
<HistoryIcon size={13} /> Run History
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("studio"); close(); }}>
|
||||
<Branch size={13} /> Process Studio
|
||||
<Branch size={13} /> Process Wizard
|
||||
<span className="cmd-hint">design & publish a new process</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("settings"); close(); }}>
|
||||
@@ -120,7 +120,7 @@ export default function CommandBar() {
|
||||
onSelect={async () => {
|
||||
close();
|
||||
if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; }
|
||||
await startInstance(sc.defKey, `MC demo · ${new Date().toLocaleString()}`);
|
||||
await startInstance(sc.defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
|
||||
}}
|
||||
>
|
||||
<Play size={13} /> Start new instance of "{sc.defName}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Tabbed Inspector for the selected step.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Shield, Doc, Layers, Pulse, History } from "./icons";
|
||||
import { Shield, Doc, Layers, Pulse, HistoryIcon } from "./icons";
|
||||
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
done: "Done", running: "Running", queued: "Queued",
|
||||
@@ -12,7 +12,7 @@ const TABS: Array<{ id: "overview" | "rules" | "evidence" | "raw" | "runs"; labe
|
||||
{ id: "overview", label: "Overview", Icon: Doc },
|
||||
{ id: "rules", label: "Rules", Icon: Shield },
|
||||
{ id: "evidence", label: "Evidence", Icon: Pulse },
|
||||
{ id: "runs", label: "Runs", Icon: History },
|
||||
{ id: "runs", label: "Runs", Icon: HistoryIcon },
|
||||
{ id: "raw", label: "Raw", Icon: Layers },
|
||||
];
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`);
|
||||
await startInstance(defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,9 @@ export const Doc = (p: P) => <Svg {...p}><path d="M7 3h7l4 4v14H7zM14 3v4h4" /><
|
||||
export const Cog = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" /></Svg>;
|
||||
export const Flag = (p: P) => <Svg {...p}><path d="M5 21V4h13l-2 4 2 4H5" /></Svg>;
|
||||
export const Arrow = (p: P) => <Svg {...p}><path d="M5 12h14M13 5l7 7-7 7" /></Svg>;
|
||||
export const Sun = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></Svg>;
|
||||
export const Moon = (p: P) => <Svg {...p}><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></Svg>;
|
||||
|
||||
export const ArrowUp = (p: P) => <Svg {...p}><path d="M12 19V5M5 12l7-7 7 7" /></Svg>;
|
||||
export const ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
|
||||
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>;
|
||||
@@ -35,5 +38,6 @@ export const Search = (p: P) => <Svg {...p}><circle cx="11" cy="11" r="7" /><pat
|
||||
export const Spark = (p: P) => <Svg {...p}><polyline points="3,16 8,11 12,15 17,8 21,12" /></Svg>;
|
||||
export const Pulse = (p: P) => <Svg {...p}><path d="M3 12h4l3-8 4 16 3-8h4" /></Svg>;
|
||||
export const Layers = (p: P) => <Svg {...p}><path d="M12 3l9 5-9 5-9-5zM3 13l9 5 9-5M3 18l9 5 9-5" /></Svg>;
|
||||
export const History = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
|
||||
export const HistoryIcon = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
|
||||
export const Home = (p: P) => <Svg {...p}><path d="M4 11l8-7 8 7v9h-5v-6h-6v6H4z" /></Svg>;
|
||||
export const ChevronRight = (p: P) => <Svg {...p}><path d="M9 18l6-6-6-6" /></Svg>;
|
||||
|
||||
@@ -81,7 +81,7 @@ function baseTour(familyId: string): TourStep[] {
|
||||
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
|
||||
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
|
||||
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
|
||||
{ id: "t4", anchor: "command", title: "Drive the demo with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
|
||||
{ id: "t4", anchor: "command", title: "Drive Mission Control with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
|
||||
{ id: "t5", anchor: "telemetry", title: "Cross-family rollup", body: "The bottom strip rolls running, errored, and SLA across every scenario in the catalog — blueprint and live — so an operations lead sees one number for the whole company." },
|
||||
{ id: "t6", anchor: "graph", title: "You're in control", body: "That's the loop. Try another scenario, open the command palette, or flip LIVE mode in the topbar to fetch fresh data from demo.flow-master.ai right in the browser." },
|
||||
];
|
||||
|
||||
+440
@@ -8,6 +8,17 @@
|
||||
- light paper canvas + navy frame + amber accent
|
||||
- 1px rules, square edges, monospace operational density
|
||||
===================================================================== */
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bp-paper: #0c1322;
|
||||
--bp-paper-2: #1a2740;
|
||||
--bp-paper-3: #243453;
|
||||
--bp-navy: #e6edf7;
|
||||
--bp-navy-2: #d5dde9;
|
||||
--bp-muted: #7a8aa8;
|
||||
--bp-muted-2: #4a5b80;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
|
||||
--bp-paper: #f5f7fb;
|
||||
@@ -108,6 +119,8 @@ kbd {
|
||||
Topbar (industrial instrument strip)
|
||||
===================================================================== */
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 190;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: stretch;
|
||||
@@ -1143,6 +1156,198 @@ select.studio-input { background: var(--bp-paper); }
|
||||
.toast-warn { border-left-color: var(--bp-amber); }
|
||||
.toast-err { border-left-color: var(--bp-err); }
|
||||
|
||||
/* =====================================================================
|
||||
Login Page
|
||||
===================================================================== */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bp-paper);
|
||||
background-image: linear-gradient(var(--bp-grid-minor) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--bp-grid-minor) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.login-card {
|
||||
width: 380px;
|
||||
background: var(--bp-paper);
|
||||
border: 1px solid var(--bp-navy);
|
||||
padding: 24px 32px;
|
||||
position: relative;
|
||||
}
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 18px; height: 18px;
|
||||
border-right: 1px solid var(--bp-navy);
|
||||
border-bottom: 1px solid var(--bp-navy);
|
||||
background: var(--bp-paper-2);
|
||||
}
|
||||
.login-head {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-head .brand-mark {
|
||||
display: inline-block;
|
||||
width: 24px; height: 24px;
|
||||
background: var(--bp-amber);
|
||||
border: 1px solid var(--bp-navy);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.login-head h1 {
|
||||
margin: 0;
|
||||
font-family: var(--bp-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bp-navy);
|
||||
}
|
||||
.login-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--bp-muted);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.login-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.login-field label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--bp-muted);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.login-input {
|
||||
background: var(--bp-paper);
|
||||
border: 1px solid var(--bp-navy);
|
||||
padding: 8px 12px;
|
||||
font-family: var(--bp-mono);
|
||||
font-size: 13px;
|
||||
color: var(--bp-navy);
|
||||
}
|
||||
.login-input:focus {
|
||||
outline: 2px solid var(--bp-amber);
|
||||
outline-offset: 0;
|
||||
}
|
||||
.login-input:disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--bp-paper-2);
|
||||
}
|
||||
.login-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.login-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--bp-navy);
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-checkbox input {
|
||||
accent-color: var(--bp-amber);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.login-link {
|
||||
font-size: 11px;
|
||||
color: var(--bp-amber);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.login-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--bp-navy);
|
||||
opacity: 0.2;
|
||||
}
|
||||
.login-divider span {
|
||||
padding: 0 10px;
|
||||
font-size: 10px;
|
||||
color: var(--bp-muted);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.login-sso-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sso-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bp-paper);
|
||||
border: 1px solid var(--bp-navy);
|
||||
color: var(--bp-navy);
|
||||
font-family: var(--bp-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sso-btn:hover:not(:disabled) {
|
||||
background: var(--bp-paper-2);
|
||||
}
|
||||
.dev-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bp-paper-2);
|
||||
border: 1px dashed var(--bp-navy);
|
||||
color: var(--bp-muted);
|
||||
font-family: var(--bp-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.dev-login-btn:hover:not(:disabled) {
|
||||
background: var(--bp-paper-3);
|
||||
color: var(--bp-navy);
|
||||
}
|
||||
.dev-login-btn:disabled, .sso-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.login-error {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bp-err-soft);
|
||||
border-left: 2px solid var(--bp-err);
|
||||
color: var(--bp-err);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Scrollbars
|
||||
===================================================================== */
|
||||
@@ -1347,3 +1552,238 @@ select.studio-input { background: var(--bp-paper); }
|
||||
[data-canvas="blueprint"] .react-flow__edge.selected .react-flow__edge-path,
|
||||
[data-canvas="blueprint"] .react-flow__edge:focus .react-flow__edge-path { stroke: var(--bp-amber); }
|
||||
[data-canvas="blueprint"] .react-flow__attribution { display: none; }
|
||||
|
||||
/* Wizard */
|
||||
.wizard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-xl) var(--space-2xl);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wizard-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.wizard-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
background: var(--paper);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wizard-step-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wizard-step-marker.active {
|
||||
background: var(--amber);
|
||||
color: var(--navy);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wizard-step-marker.past {
|
||||
color: var(--text-1);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
color: var(--paper);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wizard-step-marker.active .step-num {
|
||||
background: var(--navy);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.wizard-step-marker.past .step-num {
|
||||
background: var(--ok);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.step-chevron {
|
||||
color: var(--border);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.wizard-panel {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.wizard-panel.success-panel {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border-color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: var(--ok);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-group input, .field-group textarea {
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
color: var(--text-1);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field-group input:focus, .field-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-list, .field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-card, .field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.node-idx {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
background: var(--border);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.node-header input, .field-row input {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-1);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.node-header input:hover, .field-row input:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.node-header input:focus, .field-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--amber);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.node-meta select, .field-row select {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-md);
|
||||
background: var(--paper-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.summary-stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.summary-stats span {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
+40
-1
@@ -174,6 +174,13 @@ async function authedRequest<T>(
|
||||
export const api = {
|
||||
config: DEFAULT_CONFIG,
|
||||
|
||||
/** Set the bearer token explicitly (e.g. from SSO or login form) */
|
||||
setBearer(token: string) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
// Also set a non-httpOnly cookie for server-side middleware
|
||||
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
},
|
||||
|
||||
/** Subscribe to all API calls. Returns unsubscribe. */
|
||||
onCall(fn: ApiCallObserver): () => void {
|
||||
observers.add(fn);
|
||||
@@ -184,6 +191,38 @@ export const api = {
|
||||
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
|
||||
},
|
||||
|
||||
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
|
||||
const payload = password ? { email, password } : { email };
|
||||
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
};
|
||||
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
|
||||
}
|
||||
const data = await r.json() as { access_token: string; refresh_token?: string };
|
||||
this.setBearer(data.access_token);
|
||||
return data;
|
||||
},
|
||||
|
||||
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
||||
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
|
||||
if (r.ok) {
|
||||
return await r.json() as { enabled: boolean };
|
||||
}
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
return { enabled: false };
|
||||
},
|
||||
|
||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
|
||||
return body.items ?? [];
|
||||
@@ -256,7 +295,7 @@ export const api = {
|
||||
{
|
||||
kind: "definition",
|
||||
status: "published",
|
||||
source_context: "mc-demo-studio",
|
||||
source_context: "mission-control-studio",
|
||||
version: 1,
|
||||
...payload,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { api, type Actor } from "./api";
|
||||
|
||||
export interface CreateDraftPayload {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
source_context: string;
|
||||
config: {
|
||||
wizard: {
|
||||
marker: string;
|
||||
maxDepth: number;
|
||||
maxNodes: number;
|
||||
chat: any[];
|
||||
uploads: any[];
|
||||
panelState: any;
|
||||
debug: any[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchOperation {
|
||||
collection: string;
|
||||
op: "insert" | "update" | "delete";
|
||||
_key?: string;
|
||||
_from?: string;
|
||||
_to?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const wizardApi = {
|
||||
async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) {
|
||||
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
kind: "definition",
|
||||
status: "draft",
|
||||
...payload,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`createDraft failed: ${res.status}`);
|
||||
return res.json() as Promise<{ _key: string; name: string }>;
|
||||
},
|
||||
|
||||
async updateDraftConfig(key: string, patch: any, signal?: AbortSignal) {
|
||||
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`updateDraftConfig failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) {
|
||||
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ flow_key, ops, actor }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`applyBatch failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
|
||||
return api.startTransaction(process_definition_id, business_subject, signal);
|
||||
}
|
||||
};
|
||||
@@ -46,12 +46,10 @@ export default function Landing() {
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
FlowMaster turns the operational map of a company into living,
|
||||
typed processes — backed by humans, agents, and rules — and gives you a
|
||||
single command-center to drive them. Procurement scenarios are real,
|
||||
backed by EA2 on{" "}
|
||||
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
|
||||
AR, HCM, GL, and Service are industry blueprints showing how this
|
||||
same shell extends to any process family.
|
||||
typed processes — backed by humans, agents, and rules — and gives you
|
||||
one control surface to drive them. Every procurement, finance, people,
|
||||
and service workflow runs end-to-end through EA2 on{" "}
|
||||
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "the FlowMaster backend"}</span>.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}>
|
||||
@@ -129,7 +127,7 @@ export default function Landing() {
|
||||
</main>
|
||||
|
||||
<footer className="landing-foot">
|
||||
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
|
||||
<span className="foot-eyebrow">FlowMaster · Mission Control · live operator surface for FlowMaster processes</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import Login from "./Login";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../state/store", () => ({
|
||||
useApp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
devLoginConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Login Scene", () => {
|
||||
const mockSetScene = vi.fn();
|
||||
const mockLoginAs = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default store mock
|
||||
(useApp as any).mockImplementation((selector: any) => {
|
||||
const state = {
|
||||
setScene: mockSetScene,
|
||||
loginAs: mockLoginAs,
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
// Default api mock
|
||||
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
|
||||
});
|
||||
|
||||
it("renders standard login form elements", async () => {
|
||||
render(<Login />);
|
||||
|
||||
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
|
||||
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
|
||||
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
|
||||
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls loginAs and setScene on valid form submission", async () => {
|
||||
mockLoginAs.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(mockSetScene).toHaveBeenCalledWith('landing');
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error on login failure", async () => {
|
||||
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
|
||||
|
||||
render(<Login />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invalid credentials")).toBeDefined();
|
||||
expect(mockSetScene).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows dev-login button when feature flag is enabled", async () => {
|
||||
(api.devLoginConfig as any).mockResolvedValueOnce({ enabled: true });
|
||||
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/DEV-LOGIN/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to SSO endpoint on microsoft click", async () => {
|
||||
// We can't actually assert on window.location.href in this test environment
|
||||
// without mocking window.location, but we can verify the button renders
|
||||
// and click it to ensure no errors
|
||||
render(<Login />);
|
||||
|
||||
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
|
||||
fireEvent.click(ssoBtn);
|
||||
|
||||
// We just verify it doesn't try to call the regular login
|
||||
expect(mockLoginAs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
import { Bot } from "../components/icons";
|
||||
|
||||
export default function Login() {
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const loginAs = useApp((s) => s.loginAs);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [devLoginEnabled, setDevLoginEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if dev login is enabled
|
||||
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(email, password || undefined);
|
||||
setScene("landing");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDevLogin = async () => {
|
||||
if (!email) {
|
||||
setError("Email required for dev-login");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(email, undefined);
|
||||
setScene("landing");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSO = () => {
|
||||
window.location.href = "/api/v1/auth/microsoft/login";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-head">
|
||||
<span className="brand-mark"></span>
|
||||
<h1>FLOWMASTER AUTHENTICATION</h1>
|
||||
<div className="login-sub">MISSION CONTROL VERIFICATION REQUIRED</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="login-field">
|
||||
<label htmlFor="email">OPERATOR_ID (EMAIL)</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="login-input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="email"
|
||||
placeholder="e.g. j.doe@flow-master.ai"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-field">
|
||||
<label htmlFor="password">PASSPHRASE</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="login-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-row">
|
||||
<label className="login-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
<a href="#" className="login-link">Recover access</a>
|
||||
</div>
|
||||
|
||||
<div className="login-actions">
|
||||
<button type="submit" className="btn btn-primary btn-lg" disabled={loading || !email}>
|
||||
{loading ? "VERIFYING..." : "SIGN IN"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="login-divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
|
||||
<div className="login-sso-actions">
|
||||
<button type="button" className="sso-btn" onClick={handleSSO} disabled={loading}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
CONTINUE WITH MICROSOFT
|
||||
</button>
|
||||
|
||||
{devLoginEnabled && (
|
||||
<button type="button" className="dev-login-btn" onClick={handleDevLogin} disabled={loading || !email}>
|
||||
<Bot size={14} /> SIGN IN AS DEVELOPER (DEV-LOGIN)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// RunHistory scene: cross-scenario timeline.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { Clock, History } from "../components/icons";
|
||||
import { Clock, HistoryIcon } from "../components/icons";
|
||||
|
||||
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
|
||||
type Status = (typeof STATUS_FILTERS)[number];
|
||||
@@ -36,7 +36,7 @@ export default function RunHistory() {
|
||||
<div className="rh">
|
||||
<header className="rh-head">
|
||||
<div>
|
||||
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
|
||||
<span className="mc-hero-eyebrow"><HistoryIcon size={12} /> Run history</span>
|
||||
<h2 className="mc-hero-title">All scenarios · all runs</h2>
|
||||
</div>
|
||||
<nav className="rh-filters">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export default function SsoCallback() {
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
|
||||
useEffect(() => {
|
||||
// Process fragment identifier
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get("access_token");
|
||||
|
||||
if (accessToken) {
|
||||
// Clean URL hash so tokens aren't left in the address bar
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
|
||||
// Store token
|
||||
api.setBearer(accessToken);
|
||||
|
||||
// Update state
|
||||
useApp.setState({ isAuthed: true });
|
||||
|
||||
// Fetch user profile and sync state
|
||||
api.me().then((me) => {
|
||||
useApp.setState({
|
||||
actor: { mode: "direct_user", user_id: me.user_id },
|
||||
userDisplayName: me.display_name ?? null,
|
||||
userEmail: me.email
|
||||
});
|
||||
useApp.getState().pushToast("ok", `Signed in as ${me.email}`);
|
||||
setScene("landing");
|
||||
}).catch(err => {
|
||||
useApp.getState().pushToast("err", `Failed to get profile: ${err.message}`);
|
||||
setScene("login");
|
||||
});
|
||||
} else {
|
||||
useApp.getState().pushToast("err", "No access token found in URL");
|
||||
setScene("login");
|
||||
}
|
||||
}, [setScene]);
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px' }}>
|
||||
<span className="spin" style={{ width: '24px', height: '24px', marginBottom: '16px' }}></span>
|
||||
<div className="mono" style={{ fontSize: '12px', letterSpacing: '0.1em' }}>PROCESSING AUTHENTICATION...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Studio scene — design a new FlowMaster process and publish it to EA2.
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons";
|
||||
|
||||
type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end";
|
||||
|
||||
interface DraftNode {
|
||||
id: string;
|
||||
type: NodeKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DraftEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NODES: DraftNode[] = [
|
||||
{ id: "start", type: "start", label: "Request received" },
|
||||
{ id: "review", type: "human_task", label: "Manager review" },
|
||||
{ id: "approved", type: "end", label: "Approved" },
|
||||
];
|
||||
const DEFAULT_EDGES: DraftEdge[] = [
|
||||
{ id: "e1", source: "start", target: "review" },
|
||||
{ id: "e2", source: "review", target: "approved" },
|
||||
];
|
||||
|
||||
const KIND_ICON: Record<NodeKind, (p: { size?: number }) => React.ReactElement> = {
|
||||
start: Branch,
|
||||
end: Branch,
|
||||
human_task: User,
|
||||
agent_task: Bot,
|
||||
service_task: Cog,
|
||||
};
|
||||
|
||||
const ORG_ID = "a0000000-0000-0000-0000-000000000010";
|
||||
|
||||
export default function Studio() {
|
||||
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 [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8));
|
||||
const [displayName, setDisplayName] = useState("My Process");
|
||||
const [hub, setHub] = useState("procurement");
|
||||
const [description, setDescription] = useState("Demo process designed in the Studio.");
|
||||
const [nodes, setNodes] = useState<DraftNode[]>(DEFAULT_NODES);
|
||||
const [edges, setEdges] = useState<DraftEdge[]>(DEFAULT_EDGES);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null);
|
||||
|
||||
const canPublish = mode === "live" && !!actor?.user_id;
|
||||
|
||||
const addNode = () => {
|
||||
const idx = nodes.length;
|
||||
setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]);
|
||||
};
|
||||
|
||||
const removeNode = (id: string) => {
|
||||
setNodes(nodes.filter((n) => n.id !== id));
|
||||
setEdges(edges.filter((e) => e.source !== id && e.target !== id));
|
||||
};
|
||||
|
||||
const updateNode = (id: string, patch: Partial<DraftNode>) => {
|
||||
setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)));
|
||||
};
|
||||
|
||||
const addEdge = () => {
|
||||
if (nodes.length < 2) return;
|
||||
const idx = edges.length;
|
||||
setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]);
|
||||
};
|
||||
|
||||
const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id));
|
||||
|
||||
const updateEdge = (id: string, patch: Partial<DraftEdge>) => {
|
||||
setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e)));
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (!canPublish) {
|
||||
pushToast("warn", "Switch to LIVE mode + sign in to publish a process.");
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
const r = await api.createProcess({
|
||||
name,
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
description,
|
||||
hub,
|
||||
config: {
|
||||
org_id: ORG_ID,
|
||||
executable: true,
|
||||
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
||||
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
||||
},
|
||||
});
|
||||
pushToast("ok", `Published ${r.name} → ${r._key.slice(0, 8)}`);
|
||||
setLastResult({ ok: true, key: r._key });
|
||||
await refreshLive();
|
||||
} catch (e) {
|
||||
pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`);
|
||||
setLastResult({ ok: false, err: (e as Error).message });
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="studio">
|
||||
<header className="studio-head">
|
||||
<div>
|
||||
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Studio</div>
|
||||
<h2 className="mc-hero-title">Design a new process</h2>
|
||||
<div className="mc-hero-sub">Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-lg" onClick={publish} disabled={!valid || publishing}>
|
||||
{publishing ? <span className="spin" /> : <Play size={13} />} Publish to EA2
|
||||
{!canPublish && <span className="preview-marker">live + sign-in required</span>}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="studio-grid">
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">Definition</h3>
|
||||
<label className="studio-field">
|
||||
<span>name (unique slug)</span>
|
||||
<input className="studio-input mono" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>display name</span>
|
||||
<input className="studio-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>hub</span>
|
||||
<select className="studio-input" value={hub} onChange={(e) => setHub(e.target.value)}>
|
||||
<option value="procurement">procurement</option>
|
||||
<option value="finance">finance</option>
|
||||
<option value="people">people</option>
|
||||
<option value="service">service</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>description</span>
|
||||
<textarea className="studio-input" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">
|
||||
Nodes
|
||||
<span className="panel-count">{nodes.length}</span>
|
||||
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addNode}>+ node</button>
|
||||
</h3>
|
||||
<div className="studio-rows">
|
||||
{nodes.map((n) => {
|
||||
const Icon = KIND_ICON[n.type] ?? Cog;
|
||||
return (
|
||||
<div key={n.id} className="studio-row">
|
||||
<span className="studio-row-id mono">{n.id}</span>
|
||||
<Icon size={13} />
|
||||
<select className="studio-input" value={n.type} onChange={(e) => updateNode(n.id, { type: e.target.value as NodeKind })}>
|
||||
<option value="start">start</option>
|
||||
<option value="human_task">human_task</option>
|
||||
<option value="agent_task">agent_task</option>
|
||||
<option value="service_task">service_task</option>
|
||||
<option value="end">end</option>
|
||||
</select>
|
||||
<input className="studio-input" value={n.label} onChange={(e) => updateNode(n.id, { label: e.target.value })} />
|
||||
<button className="link-btn" onClick={() => removeNode(n.id)} title="Remove"><Close size={11} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">
|
||||
Edges
|
||||
<span className="panel-count">{edges.length}</span>
|
||||
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addEdge}>+ edge</button>
|
||||
</h3>
|
||||
<div className="studio-rows">
|
||||
{edges.map((e) => (
|
||||
<div key={e.id} className="studio-row">
|
||||
<span className="studio-row-id mono">{e.id}</span>
|
||||
<select className="studio-input" value={e.source} onChange={(ev) => updateEdge(e.id, { source: ev.target.value })}>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
||||
</select>
|
||||
<span className="mono" style={{ color: "var(--text-3)" }}>→</span>
|
||||
<select className="studio-input" value={e.target} onChange={(ev) => updateEdge(e.id, { target: ev.target.value })}>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
||||
</select>
|
||||
<button className="link-btn" onClick={() => removeEdge(e.id)} title="Remove"><Close size={11} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">JSON preview</h3>
|
||||
<pre className="raw-json"><code>{JSON.stringify({
|
||||
name, display_name: displayName, hub, description,
|
||||
kind: "definition", status: "published", version: 1,
|
||||
config: {
|
||||
org_id: ORG_ID, executable: true,
|
||||
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
||||
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
||||
},
|
||||
}, null, 2)}</code></pre>
|
||||
{lastResult && (
|
||||
<div className={`studio-result ${lastResult.ok ? "ok" : "err"}`}>
|
||||
{lastResult.ok ? (
|
||||
<>
|
||||
<Check size={12} /> Published as <span className="mono">{lastResult.key?.slice(0, 12)}</span>
|
||||
<button className="link-btn" onClick={() => setScene("mission")} style={{ marginLeft: "auto" }}>Open in Mission Control</button>
|
||||
</>
|
||||
) : (
|
||||
<><Close size={12} /> <span className="mono">{lastResult.err?.slice(0, 200)}</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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 ops: BatchOperation[] = draft.nodes.map(n => ({
|
||||
collection: "ea2_node",
|
||||
op: "insert",
|
||||
flow_key: draft.flowKey,
|
||||
...n
|
||||
}));
|
||||
|
||||
// Link them sequentially
|
||||
for (let i = 0; i < draft.nodes.length - 1; i++) {
|
||||
ops.push({
|
||||
collection: "ea2_edge",
|
||||
op: "insert",
|
||||
flow_key: draft.flowKey,
|
||||
_from: `ea2_node/${draft.nodes[i]._key}`,
|
||||
_to: `ea2_node/${draft.nodes[i+1]._key}`,
|
||||
kind: "next"
|
||||
});
|
||||
}
|
||||
|
||||
await wizardApi.applyBatch(draft.flowKey, ops, actor);
|
||||
updateDraft({ step: "Generate" });
|
||||
pushToast("ok", "Structure saved");
|
||||
} catch (err: any) {
|
||||
pushToast("err", `Save failed: ${err.message}`);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataSave = async () => {
|
||||
if (!draft.flowKey || !actor) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
// In a real app we'd save proper data models here.
|
||||
// For the spike we just move forward and do a dummy update
|
||||
await wizardApi.updateDraftConfig(draft.flowKey, {
|
||||
config: { wizard: { panelState: { fields: draft.fields } } }
|
||||
});
|
||||
updateDraft({ step: "Validate" });
|
||||
pushToast("ok", "Data requirements saved");
|
||||
} 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[] = [
|
||||
{
|
||||
collection: "ea2_flow",
|
||||
op: "update",
|
||||
_key: draft.flowKey,
|
||||
status: "published"
|
||||
}
|
||||
];
|
||||
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 renderStepNav = () => (
|
||||
<div className="wizard-progress">
|
||||
{STEPS.map((step, i) => {
|
||||
const isActive = step === draft.step;
|
||||
const isPast = STEPS.indexOf(step) < STEPS.indexOf(draft.step);
|
||||
return (
|
||||
<div key={step} className={`wizard-step-marker ${isActive ? 'active' : ''} ${isPast ? 'past' : ''}`}>
|
||||
<span className="step-num">{i + 1}</span>
|
||||
<span className="step-label">{step}</span>
|
||||
{i < STEPS.length - 1 && <ChevronRight size={12} className="step-chevron" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
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-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}
|
||||
title="POST /api/ea2/flow"
|
||||
>
|
||||
{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">Review proposed structure</h3>
|
||||
<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">Human Task</option>
|
||||
<option value="agent">Agent Task</option>
|
||||
<option value="system">System Task</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStructureSave}
|
||||
disabled={working}
|
||||
title="POST /api/ea2/apply-batch"
|
||||
>
|
||||
{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>
|
||||
<div className="field-list">
|
||||
{draft.fields.map((f, i) => (
|
||||
<div key={i} className="field-row">
|
||||
<input
|
||||
placeholder="Field 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}
|
||||
title="PUT /api/ea2/flow/{key}"
|
||||
>
|
||||
{working ? 'Saving...' : 'Confirm Data →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.step === "Validate" && (
|
||||
<div className="wizard-panel">
|
||||
<h3 className="panel-h">When should this process do something different? (Rules)</h3>
|
||||
<div className="field-list">
|
||||
{draft.rules.map((r, i) => (
|
||||
<div key={i} className="field-row">
|
||||
<input
|
||||
placeholder="e.g. If cost > 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}
|
||||
title="PUT /api/ea2/flow/{key}"
|
||||
>
|
||||
{working ? 'Saving...' : 'Review Process →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.step === "Draft saved" && (
|
||||
<div className="wizard-panel">
|
||||
<h3 className="panel-h">Review & Publish</h3>
|
||||
<p>Your process <strong>{draft.name || 'Untitled'}</strong> is ready to be published to EA2.</p>
|
||||
<div className="summary-stats">
|
||||
<div><span>Steps:</span> {draft.nodes.length}</div>
|
||||
<div><span>Data fields:</span> {draft.fields.length}</div>
|
||||
<div><span>Rules:</span> {draft.rules.length}</div>
|
||||
</div>
|
||||
<div className="actions-row">
|
||||
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Edit</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePublish}
|
||||
disabled={working}
|
||||
title="POST /api/ea2/apply-batch"
|
||||
>
|
||||
{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>Process definition <code>{publishedKey}</code> is now active in EA2.</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}
|
||||
title="POST /api/runtime/transactions"
|
||||
>
|
||||
Start First Instance Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
-7
@@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
||||
import { api, type ApiCall, type Actor } from "../lib/api";
|
||||
import type { ProcessScenario } from "../data/types";
|
||||
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings";
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
|
||||
export type DataMode = "snapshot" | "live";
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
@@ -82,15 +82,18 @@ interface AppState {
|
||||
actor: Actor | null;
|
||||
userEmail: string;
|
||||
userDisplayName: string | null;
|
||||
isAuthed: boolean;
|
||||
setUserEmail: (e: string) => void;
|
||||
loginAs: (email: string) => Promise<void>;
|
||||
loginAs: (email: string, password?: string) => Promise<void>;
|
||||
|
||||
/** Live polling */
|
||||
/** Live polling — lightweight tick that only refreshes the active
|
||||
* scenario's work-items + headline runtime. Full refresh is manual. */
|
||||
pollEverySec: number;
|
||||
setPollEverySec: (n: number) => void;
|
||||
pollTimer: number | null;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
pollLiveTick: () => Promise<void>;
|
||||
|
||||
/** API call log */
|
||||
consoleOpen: boolean;
|
||||
@@ -240,28 +243,32 @@ export const useApp = create<AppState>((set, get) => {
|
||||
actor: null,
|
||||
userEmail: prefs.email ?? "dev@flow-master.ai",
|
||||
userDisplayName: null,
|
||||
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
|
||||
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
|
||||
loginAs: async (email) => {
|
||||
loginAs: async (email, password) => {
|
||||
api.clearToken();
|
||||
api.config = { ...api.config, email };
|
||||
set({ userEmail: email });
|
||||
persist();
|
||||
try {
|
||||
await api.signIn(email, password);
|
||||
const me = await api.me();
|
||||
set({
|
||||
actor: { mode: "direct_user", user_id: me.user_id },
|
||||
userDisplayName: me.display_name ?? null,
|
||||
isAuthed: true,
|
||||
});
|
||||
get().pushToast("ok", `Signed in as ${me.email}`);
|
||||
if (get().mode === "live") await get().refreshLive();
|
||||
} catch (e) {
|
||||
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
pollEverySec: prefs.pollEverySec ?? 8,
|
||||
pollEverySec: prefs.pollEverySec ?? 30,
|
||||
setPollEverySec: (n) => {
|
||||
set({ pollEverySec: Math.max(2, Math.min(120, n)) });
|
||||
set({ pollEverySec: Math.max(5, Math.min(300, n)) });
|
||||
persist();
|
||||
if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
|
||||
},
|
||||
@@ -273,7 +280,7 @@ export const useApp = create<AppState>((set, get) => {
|
||||
const id = window.setInterval(() => {
|
||||
const s = get();
|
||||
if (s.mode !== "live" || s.liveLoading) return;
|
||||
void s.refreshLive();
|
||||
void s.pollLiveTick();
|
||||
}, get().pollEverySec * 1000);
|
||||
set({ pollTimer: id });
|
||||
},
|
||||
@@ -283,6 +290,39 @@ export const useApp = create<AppState>((set, get) => {
|
||||
if (id) window.clearInterval(id);
|
||||
set({ pollTimer: null });
|
||||
},
|
||||
pollLiveTick: async () => {
|
||||
const s = get();
|
||||
if (s.mode !== "live" || s.liveLoading) return;
|
||||
try {
|
||||
const workItems = await api.workItems();
|
||||
const sc = s.scenarios.find((x) => x.id === s.scenarioId);
|
||||
let headlineRt = null;
|
||||
if (sc?.headlineTx) {
|
||||
headlineRt = await api.transaction(sc.headlineTx);
|
||||
}
|
||||
const updatedScenarios = s.scenarios.map((scen) => {
|
||||
if (!scen.live) return scen;
|
||||
const myCases = workItems.filter((w) => w.definition_key === scen.defKey);
|
||||
const newQueue = myCases.slice(0, 8).map((c, i) => ({
|
||||
id: `${c.transaction_id || scen.defKey}-${i}`,
|
||||
stepId: scen.queue[i]?.stepId ?? scen.defaultStepId,
|
||||
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
|
||||
waitingOn: (c.status === "running" || c.status === "waiting_for_user") ? "approval" as const
|
||||
: c.status === "waiting_for_agent" ? "agent" as const
|
||||
: "input" as const,
|
||||
ageDays: c.age_days ?? 0,
|
||||
status: c.status,
|
||||
}));
|
||||
if (scen.id === s.scenarioId && headlineRt) {
|
||||
return { ...scen, queue: newQueue, raw: { ...(scen.raw as object), headlineRt } };
|
||||
}
|
||||
return { ...scen, queue: newQueue };
|
||||
});
|
||||
set({ scenarios: updatedScenarios, liveTotals: { workItems: workItems.length, distinctDefs: s.liveTotals?.distinctDefs ?? 0 }, liveFetchedAt: Date.now() });
|
||||
} catch {
|
||||
// silent — full refresh will retry
|
||||
}
|
||||
},
|
||||
|
||||
consoleOpen: prefs.consoleOpen ?? false,
|
||||
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); },
|
||||
|
||||
Reference in New Issue
Block a user