foxygit / RPA-Remisser Log in
commits tags

/webui/frontend/src/App.tsx · 14.03 KB

raw
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>
  );
}