commits
tags
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApiError, api, type Flow, type GraphEdge, type RunState, type Step, type Subflow } from "./lib/api";
import type { Schema } from "./lib/schema";
import { makeT, type Lang } from "./lib/i18n";
import { normalizeFlow } from "./lib/graph";
import { newNode } from "./lib/stepops";
import { uid } from "./lib/ids";
import { flowHasRunParams } from "./lib/runvars";
import { Header } from "./components/Header";
import { Canvas, type CanvasHandle } from "./components/Canvas";
import { CanvasTabs } from "./components/CanvasTabs";
import { Palette } from "./components/Palette";
import { RunPanel } from "./components/RunPanel";
import { VarsPanel } from "./components/VarsPanel";
import { AssistPanel } from "./components/AssistPanel";
import { SettingsDialog } from "./components/SettingsDialog";
import { InspectDialog } from "./components/InspectDialog";
import { RunDialog } from "./components/RunDialog";
import { InputsEditor } from "./components/InputsEditor";
const EMPTY: Flow = {
format: "graph",
nodes: [],
edges: [],
start: null,
settings: {},
layout: {},
inputs: [],
input_sets: {},
subflows: [],
};
export default function App() {
const [schema, setSchema] = useState<Schema | null>(null);
const [lang, setLang] = useState<Lang>(() => (localStorage.getItem("rpa_lang") as Lang) || "en");
const [closeMode, setCloseMode] = useState(() => localStorage.getItem("rpa_close_mode") || "always");
const [flowNames, setFlowNames] = useState<string[]>([]);
const [currentName, setCurrentName] = useState<string | null>(null);
const [flow, setFlow] = useState<Flow>(EMPTY);
const [dirty, setDirty] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const [settingsOpen, setSettingsOpen] = useState(false);
const [inspectOpen, setInspectOpen] = useState(false);
const [inputsEditorOpen, setInputsEditorOpen] = useState(false);
const [runDialogOpen, setRunDialogOpen] = useState(false);
const [runSubflowId, setRunSubflowId] = useState<string | null>(null);
const [activeGraph, setActiveGraph] = useState<string>("main"); // "main" | subflowId
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [blocksRefreshKey, setBlocksRefreshKey] = useState(0);
const [toast, setToast] = useState<string | null>(null);
const [runId, setRunId] = useState<string | null>(null);
const [run, setRun] = useState<RunState | null>(null);
const pollTimer = useRef<number | null>(null);
const canvasRef = useRef<CanvasHandle>(null);
// Stabil referens – annars räknas alla React Flow-noder om vid varje App-render
// (och mäts aldrig färdigt → visibility:hidden).
const t = useMemo(() => makeT(lang), [lang]);
useEffect(() => {
localStorage.setItem("rpa_lang", lang);
document.title = t("page_title");
}, [lang]); // eslint-disable-line
useEffect(() => localStorage.setItem("rpa_close_mode", closeMode), [closeMode]);
// --- init ---
useEffect(() => {
(async () => {
const s = await api.schema();
setSchema(s);
const names = await api.listFlows();
setFlowNames(names);
if (names.length) await loadFlow(names[0]);
else {
const a = newNode(s, "goto", { x: 0, y: 0 });
a.url = "https://www.1177.se/";
const b = newNode(s, "wait_for_login", { x: 300, y: 0 });
const f = normalizeFlow({
nodes: [a, b],
edges: [{ id: "e0", source: a.id!, target: b.id!, sourceHandle: "out", targetHandle: "in" }],
start: a.id,
});
setCurrentName("standard");
setFlow(f);
setDirty(true);
setReloadToken((n) => n + 1);
}
})();
}, []); // eslint-disable-line
const loadFlow = useCallback(async (name: string) => {
const f = normalizeFlow(await api.getFlow(name));
setCurrentName(name);
setFlow(f);
setActiveGraph("main");
setDirty(false);
setReloadToken((n) => n + 1);
}, []);
const subflows = flow.subflows ?? [];
const activeSub = activeGraph === "main" ? null : subflows.find((s) => s.id === activeGraph) ?? null;
const activeGraphSlice = useMemo(
() =>
activeSub
? { nodes: activeSub.nodes, edges: activeSub.edges, start: activeSub.start }
: { nodes: flow.nodes, edges: flow.edges, start: flow.start },
[activeSub, flow.nodes, flow.edges, flow.start],
);
const onCanvasChange = useCallback(
(partial: { nodes: Step[]; edges: GraphEdge[]; start: string | null }) => {
setFlow((prev) => {
if (activeGraph === "main") {
return { ...prev, nodes: partial.nodes, edges: partial.edges, start: partial.start };
}
return {
...prev,
subflows: (prev.subflows ?? []).map((s) =>
s.id === activeGraph ? { ...s, nodes: partial.nodes, edges: partial.edges, start: partial.start } : s,
),
};
});
setDirty(true);
},
[activeGraph],
);
const switchGraph = useCallback((key: string) => {
setActiveGraph(key);
setReloadToken((n) => n + 1); // remounta canvasen med rätt graf
}, []);
function addSubflow() {
const name = prompt(t("subflow_name_prompt"));
if (!name) return;
const sf: Subflow = { id: uid(), name: name.trim(), nodes: [], edges: [], start: null, inputs: [] };
setFlow((prev) => ({ ...prev, subflows: [...(prev.subflows ?? []), sf] }));
setDirty(true);
switchGraph(sf.id);
}
function renameSubflow(id: string, name: string) {
setFlow((prev) => ({
...prev,
subflows: (prev.subflows ?? []).map((s) => (s.id === id ? { ...s, name } : s)),
}));
setDirty(true);
}
function deleteSubflow(id: string) {
const used = [flow, ...(flow.subflows ?? [])].some((g) =>
(g.nodes ?? []).some((n) => n.type === "call_flow" && n.target === "#" + id),
);
const msg = used ? t("subflow_in_use") : t("subflow_delete_confirm");
if (!confirm(msg)) return;
setFlow((prev) => ({ ...prev, subflows: (prev.subflows ?? []).filter((s) => s.id !== id) }));
setDirty(true);
switchGraph("main");
}
const openExternal = useCallback(
(name: string) => {
if (dirty && !confirm(t("leave_unsaved"))) return;
loadFlow(name);
},
[dirty, loadFlow, t],
);
// Lägg till via canvasens imperativa API → ingen remount, ingen viewport-tapp.
const addStepType = useCallback((type: string) => canvasRef.current?.addNodeType(type), []);
const insertBlock = useCallback(async (name: string) => {
try {
const block = await api.getBlock(name);
canvasRef.current?.insertGraph(block.nodes ?? [], block.edges ?? []);
} catch {
/* ignore */
}
}, []);
async function saveSelectionAsBlock() {
const chosen = activeGraphSlice.nodes.filter((s) => selectedIds.includes(s.id!));
if (chosen.length === 0) {
setToast(t("block_select_first"));
return;
}
const ids = new Set(chosen.map((n) => n.id));
const innerEdges = (activeGraphSlice.edges ?? []).filter((e) => ids.has(e.source) && ids.has(e.target));
const name = prompt(t("block_name_prompt"));
if (!name) return;
const description = prompt(t("block_desc_prompt")) || "";
try {
await api.saveBlock(name, { name, description, nodes: chosen, edges: innerEdges, settings: {} });
setBlocksRefreshKey((n) => n + 1);
setToast(t("block_saved"));
} catch (e) {
const detail = e instanceof ApiError ? JSON.stringify(e.detail) : String(e);
setToast(t("save_failed", { msg: detail }));
}
}
const save = useCallback(async (): Promise<boolean> => {
if (!currentName) return false;
try {
await api.saveFlow(currentName, flow);
setDirty(false);
const names = await api.listFlows();
setFlowNames(names);
return true;
} catch (e) {
const detail = e instanceof ApiError ? JSON.stringify(e.detail) : String(e);
setToast(t("save_failed", { msg: detail }));
return false;
}
}, [currentName, flow, t]);
async function newFlow() {
const name = prompt(t("new_flow_prompt"));
if (!name || !schema) return;
const f: Flow = { ...EMPTY };
setCurrentName(name);
setFlow(f);
setReloadToken((n) => n + 1);
try {
await api.saveFlow(name, f);
setFlowNames(await api.listFlows());
setDirty(false);
} catch {
setDirty(true);
}
}
async function deleteFlow() {
if (!currentName) return;
if (!confirm(t("delete_flow_confirm", { name: currentName }))) return;
await api.deleteFlow(currentName);
const names = await api.listFlows();
setFlowNames(names);
if (names.length) await loadFlow(names[0]);
else {
setCurrentName(null);
setFlow(EMPTY);
setReloadToken((n) => n + 1);
}
}
const stopPoll = () => {
if (pollTimer.current) window.clearTimeout(pollTimer.current);
pollTimer.current = null;
};
const poll = useCallback((id: string) => {
api.run(id).then((data) => {
setRun(data);
if (data.status === "running" || data.browser_open) {
pollTimer.current = window.setTimeout(() => poll(id), 1000);
}
});
}, []);
async function startRunWithVars(initialVars: Record<string, string>, subflowId?: string | null) {
if (!currentName) return;
await save();
stopPoll();
setRun(null);
const { run_id } = await api.runFlow(currentName, closeMode, initialVars, subflowId ?? undefined);
setRunId(run_id);
poll(run_id);
}
function onClickRun() {
if (!currentName) return;
setRunSubflowId(null);
if (flowHasRunParams(flow)) setRunDialogOpen(true);
else startRunWithVars({});
}
function runSubflow(id: string) {
if (!currentName) return;
const sf = subflows.find((s) => s.id === id);
setRunSubflowId(id);
if ((sf?.inputs?.length ?? 0) > 0) setRunDialogOpen(true);
else startRunWithVars({}, id);
}
// Flödet som kördialogen jobbar mot (subgraf-inputs när en subflow-flik körs).
const runDialogFlow: Flow =
runSubflowId != null
? { ...flow, inputs: subflows.find((s) => s.id === runSubflowId)?.inputs ?? [], input_sets: {} }
: flow;
function saveValueSet(name: string, values: Record<string, string>) {
setFlow((prev) => ({ ...prev, input_sets: { ...(prev.input_sets ?? {}), [name]: values } }));
setDirty(true);
}
useEffect(() => stopPoll, []);
if (!schema) return <div className="loading">…</div>;
return (
<div className="app">
<Header
t={t}
lang={lang}
setLang={setLang}
flowNames={flowNames}
currentName={currentName}
onSelect={loadFlow}
onNew={newFlow}
onDelete={deleteFlow}
onSave={save}
onRun={onClickRun}
dirty={dirty}
running={run?.status === "running"}
closeMode={closeMode}
setCloseMode={setCloseMode}
onOpenSettings={() => setSettingsOpen(true)}
onOpenInspect={() => setInspectOpen(true)}
onOpenInputs={() => setInputsEditorOpen(true)}
/>
<main>
<Palette
schema={schema}
lang={lang}
t={t}
blocksRefreshKey={blocksRefreshKey}
onSaveSelectionAsBlock={saveSelectionAsBlock}
onAddStep={addStepType}
onInsertBlock={insertBlock}
/>
<div className="canvas-col">
<CanvasTabs
t={t}
subflows={subflows}
active={activeGraph}
running={run?.status === "running"}
onSelect={switchGraph}
onAdd={addSubflow}
onRename={renameSubflow}
onDelete={deleteSubflow}
onRunSubflow={runSubflow}
/>
<Canvas
ref={canvasRef}
key={reloadToken + ":" + activeGraph}
graph={activeGraphSlice}
schema={schema}
lang={lang}
t={t}
subflowTargets={subflows
.filter((s) => s.id !== activeGraph)
.map((s) => ({ id: s.id, name: s.name }))}
externalFlows={flowNames.filter((n) => n !== currentName)}
onOpenSubflow={switchGraph}
onOpenExternal={openExternal}
onChange={onCanvasChange}
onSelectionChange={setSelectedIds}
/>
</div>
<aside className="side">
<AssistPanel
t={t}
currentName={currentName}
flow={flow}
onApply={(f) => {
setFlow((prev) => normalizeFlow({ ...f, inputs: prev.inputs, input_sets: prev.input_sets }));
setDirty(true);
setReloadToken((n) => n + 1);
}}
/>
<RunPanel t={t} runId={runId} run={run} />
<VarsPanel t={t} vars={run?.vars ?? {}} />
</aside>
</main>
<SettingsDialog
open={settingsOpen}
schema={schema}
lang={lang}
t={t}
settings={flow.settings}
onClose={() => setSettingsOpen(false)}
onSave={(settings) => {
setFlow((prev) => ({ ...prev, settings }));
setDirty(true);
setSettingsOpen(false);
}}
/>
<InspectDialog
open={inspectOpen}
t={t}
onClose={() => setInspectOpen(false)}
onCreateStep={(s) => {
canvasRef.current?.addNode(s);
setInspectOpen(false);
}}
/>
<InputsEditor
open={inputsEditorOpen}
t={t}
lang={lang}
schema={schema}
flow={flow}
onClose={() => setInputsEditorOpen(false)}
onChange={({ inputs, input_sets }) => {
setFlow((prev) => ({ ...prev, inputs, input_sets }));
setDirty(true);
}}
/>
<RunDialog
open={runDialogOpen}
t={t}
flow={runDialogFlow}
flowName={runSubflowId ? `${currentName}#${runSubflowId}` : currentName}
onClose={() => setRunDialogOpen(false)}
onRun={(vars) => startRunWithVars(vars, runSubflowId)}
onSaveSet={saveValueSet}
/>
{toast && (
<div className="toast" onClick={() => setToast(null)}>
{toast}
</div>
)}
</div>
);
}