feat(workflows): advanced text mode — export/import the flow as JSON
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
This commit is contained in:
@@ -230,6 +230,13 @@
|
|||||||
"namePlaceholder": "Workflow-Name",
|
"namePlaceholder": "Workflow-Name",
|
||||||
"when": "Wenn",
|
"when": "Wenn",
|
||||||
"cleanUp": "Layout aufräumen",
|
"cleanUp": "Layout aufräumen",
|
||||||
|
"textView": "Text",
|
||||||
|
"canvasView": "Canvas",
|
||||||
|
"loadText": "In Editor laden",
|
||||||
|
"textLoaded": "Geladen – prüfen und speichern",
|
||||||
|
"textNeedsArrays": "Benötigt „nodes“- und „edges“-Arrays",
|
||||||
|
"textNeedsTrigger": "Benötigt genau einen Trigger-Knoten",
|
||||||
|
"textHint": "Der gesamte Ablauf als JSON – zum Teilen kopieren oder an ein LLM geben, oder einen Ablauf einfügen und in den Editor laden. Nach dem Import „Layout aufräumen“ klicken.",
|
||||||
"saved": "Workflow gespeichert",
|
"saved": "Workflow gespeichert",
|
||||||
"saveFailed": "Speichern fehlgeschlagen",
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
"badJson": "Konfiguration ist kein gültiges JSON",
|
"badJson": "Konfiguration ist kein gültiges JSON",
|
||||||
|
|||||||
@@ -230,6 +230,13 @@
|
|||||||
"namePlaceholder": "Workflow name",
|
"namePlaceholder": "Workflow name",
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"cleanUp": "Clean up layout",
|
"cleanUp": "Clean up layout",
|
||||||
|
"textView": "Text",
|
||||||
|
"canvasView": "Canvas",
|
||||||
|
"loadText": "Load into editor",
|
||||||
|
"textLoaded": "Loaded — review and Save",
|
||||||
|
"textNeedsArrays": "Needs \"nodes\" and \"edges\" arrays",
|
||||||
|
"textNeedsTrigger": "Needs exactly one trigger node",
|
||||||
|
"textHint": "The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.",
|
||||||
"saved": "Workflow saved",
|
"saved": "Workflow saved",
|
||||||
"saveFailed": "Could not save",
|
"saveFailed": "Could not save",
|
||||||
"badJson": "Config is not valid JSON",
|
"badJson": "Config is not valid JSON",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import dagre from '@dagrejs/dagre';
|
import dagre from '@dagrejs/dagre';
|
||||||
import { ArrowLeft, Save, Trash2, Wand2 } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
|
||||||
import { Button, Loading } from '../../../components/common';
|
import { Button, Loading } from '../../../components/common';
|
||||||
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
||||||
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
||||||
@@ -136,6 +136,47 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60);
|
setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60);
|
||||||
}, [edges, setNodes]);
|
}, [edges, setNodes]);
|
||||||
|
|
||||||
|
// --- Advanced text mode: the whole flow as JSON (share / LLM round-trip) ---
|
||||||
|
const [textMode, setTextMode] = useState(false);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [textErr, setTextErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const serializeFlow = () => JSON.stringify({
|
||||||
|
name,
|
||||||
|
trigger_type: triggerType,
|
||||||
|
enabled,
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
|
||||||
|
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
|
||||||
|
})),
|
||||||
|
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
|
||||||
|
}, null, 2);
|
||||||
|
|
||||||
|
const openText = () => { setText(serializeFlow()); setTextErr(null); setTextMode(true); };
|
||||||
|
|
||||||
|
const applyText = () => {
|
||||||
|
let p: any;
|
||||||
|
try { p = JSON.parse(text); } catch (e) { setTextErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
|
||||||
|
if (!Array.isArray(p.nodes) || !Array.isArray(p.edges)) { setTextErr(t('workflows.editor.textNeedsArrays', 'Needs "nodes" and "edges" arrays') as string); return; }
|
||||||
|
if (p.nodes.filter((n: any) => n.type === 'trigger').length !== 1) { setTextErr(t('workflows.editor.textNeedsTrigger', 'Needs exactly one trigger node') as string); return; }
|
||||||
|
const tt = p.trigger_type || triggerType;
|
||||||
|
if (p.name != null) setName(p.name);
|
||||||
|
if (p.trigger_type) setTriggerType(p.trigger_type);
|
||||||
|
if (p.enabled != null) setEnabled(!!p.enabled);
|
||||||
|
setNodes(p.nodes.map((n: any) => ({
|
||||||
|
id: n.node_key, type: 'wf', position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||||
|
data: { nodeType: n.type, config: n.config || {}, triggerType: tt },
|
||||||
|
})));
|
||||||
|
setEdges(p.edges.map((e: any, i: number) => ({
|
||||||
|
id: `e${i}`, source: e.from_node, target: e.to_node, sourceHandle: e.from_handle || undefined, label: e.from_handle || undefined,
|
||||||
|
})));
|
||||||
|
setTextErr(null);
|
||||||
|
setTextMode(false);
|
||||||
|
toast.success(t('workflows.editor.textLoaded', 'Loaded — review and Save') as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = () => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Copied') as string); };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workflow) return;
|
if (!workflow) return;
|
||||||
setName(workflow.name);
|
setName(workflow.name);
|
||||||
@@ -230,13 +271,17 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||||
{t('workflows.enabled', 'Enabled')}
|
{t('workflows.enabled', 'Enabled')}
|
||||||
</label>
|
</label>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => (textMode ? setTextMode(false) : openText())} leftIcon={<Code className="w-4 h-4" />}>
|
||||||
|
{textMode ? t('workflows.editor.canvasView', 'Canvas') : t('workflows.editor.textView', 'Text')}
|
||||||
|
</Button>
|
||||||
<Button variant="primary" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} leftIcon={<Save className="w-4 h-4" />}>
|
<Button variant="primary" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} leftIcon={<Save className="w-4 h-4" />}>
|
||||||
{t('common.saveChanges', 'Save changes')}
|
{t('common.saveChanges', 'Save changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!textMode && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{PALETTE.map((type) => (
|
{PALETTE.map((type) => (
|
||||||
<button
|
<button
|
||||||
@@ -253,7 +298,25 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
<Wand2 className="w-3.5 h-3.5" /> {t('workflows.editor.cleanUp', 'Clean up layout')}
|
<Wand2 className="w-3.5 h-3.5" /> {t('workflows.editor.cleanUp', 'Clean up layout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{textMode ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('workflows.editor.textHint', 'The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.')}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={text} onChange={(e) => setText(e.target.value)} spellCheck={false}
|
||||||
|
className="w-full font-mono text-xs p-2 rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||||
|
style={{ height: '62vh' }}
|
||||||
|
/>
|
||||||
|
{textErr && <p className="text-xs text-red-600 dark:text-red-400">{textErr}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={copyText}>{t('common.copy', 'Copy')}</Button>
|
||||||
|
<Button variant="primary" onClick={applyText}>{t('workflows.editor.loadText', 'Load into editor')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex gap-3" style={{ height: '70vh' }}>
|
<div className="flex gap-3" style={{ height: '70vh' }}>
|
||||||
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -286,6 +349,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user