fix(workflows): dark-mode canvas + readable nodes + structured config
Addresses editor UX feedback: - Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock controls, minimap and selection render dark instead of white-on-black. - Readable nodes: show a human label derived from type+config (e.g. 'Invoice paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date') instead of the raw node_key, and label each output handle on the node (yes/no, confirm/deny, loop/exit) so branching is self-explanatory. - Structured config: replace the raw-JSON textarea with a per-node form (NodeConfigPanel) — dropdowns for action/condition/recipient/operator, typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander remains for anything the form doesn't cover. - en + native de strings for all of it. tsc 0 errors, build green.
This commit is contained in:
@@ -228,12 +228,36 @@
|
||||
},
|
||||
"editor": {
|
||||
"namePlaceholder": "Workflow-Name",
|
||||
"config": "Konfiguration (JSON)",
|
||||
"applyConfig": "Konfiguration übernehmen",
|
||||
"configApplied": "Konfiguration übernommen (zum Speichern nicht vergessen)",
|
||||
"badJson": "Konfiguration ist kein gültiges JSON",
|
||||
"when": "Wenn",
|
||||
"saved": "Workflow gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen"
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"badJson": "Konfiguration ist kein gültiges JSON",
|
||||
"showAdvanced": "Erweitert (JSON)",
|
||||
"hideAdvanced": "Erweitert ausblenden (JSON)",
|
||||
"triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
|
||||
"actionLabel": "Aktion",
|
||||
"recipient": "Empfänger",
|
||||
"recipientCustomer": "Kunde (berücksichtigt Geschäftszeiten)",
|
||||
"recipientAdmin": "Admin (sofort gesendet)",
|
||||
"emailTemplate": "E-Mail-Vorlagenschlüssel",
|
||||
"webhookUrl": "Webhook-URL",
|
||||
"condition": "Bedingung",
|
||||
"exprField": "Feld",
|
||||
"exprOp": "Operator",
|
||||
"exprValue": "Wert",
|
||||
"conditionHint": "Führt bei „wahr“ zur „yes“-Kante, sonst zur „no“-Kante.",
|
||||
"waitType": "Warten",
|
||||
"waitUntil": "Bis zu einem Datum",
|
||||
"waitDelay": "Feste Verzögerung",
|
||||
"waitAnchor": "Warten bis",
|
||||
"days": "Tage",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Min",
|
||||
"maxIterations": "Höchstens wiederholen (mal)",
|
||||
"gatePrompt": "Frage an den Admin",
|
||||
"gatePromptPh": "z. B. Keine Zahlung erhalten – Mahnung senden?",
|
||||
"gateTimeout": "Automatisch ablaufen nach (Tagen, optional)",
|
||||
"gateHint": "Sendet dem Admin einen Bestätigen/Ablehnen-Link; führt zur „confirm“- oder „deny“-Kante."
|
||||
}
|
||||
},
|
||||
"eventTypes": {
|
||||
|
||||
@@ -228,12 +228,36 @@
|
||||
},
|
||||
"editor": {
|
||||
"namePlaceholder": "Workflow name",
|
||||
"config": "Config (JSON)",
|
||||
"applyConfig": "Apply config",
|
||||
"configApplied": "Config applied (remember to Save)",
|
||||
"badJson": "Config is not valid JSON",
|
||||
"when": "When",
|
||||
"saved": "Workflow saved",
|
||||
"saveFailed": "Could not save"
|
||||
"saveFailed": "Could not save",
|
||||
"badJson": "Config is not valid JSON",
|
||||
"showAdvanced": "Advanced (JSON)",
|
||||
"hideAdvanced": "Hide advanced (JSON)",
|
||||
"triggerHint": "The trigger is set in the toolbar above (When …).",
|
||||
"actionLabel": "Action",
|
||||
"recipient": "Recipient",
|
||||
"recipientCustomer": "Customer (respects business hours)",
|
||||
"recipientAdmin": "Admin (sent immediately)",
|
||||
"emailTemplate": "Email template key",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"condition": "Condition",
|
||||
"exprField": "Field",
|
||||
"exprOp": "Operator",
|
||||
"exprValue": "Value",
|
||||
"conditionHint": "Routes to the “yes” edge when true, “no” when false.",
|
||||
"waitType": "Wait",
|
||||
"waitUntil": "Until a date",
|
||||
"waitDelay": "A fixed delay",
|
||||
"waitAnchor": "Wait until",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"minutes": "Min",
|
||||
"maxIterations": "Repeat at most (times)",
|
||||
"gatePrompt": "Question for the admin",
|
||||
"gatePromptPh": "e.g. No payment received — send a reminder?",
|
||||
"gateTimeout": "Auto-expire after (days, optional)",
|
||||
"gateHint": "Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge."
|
||||
}
|
||||
},
|
||||
"archives": {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Structured config editor for a workflow node — dropdowns + typed fields per
|
||||
* node type, so admins don't hand-edit JSON. An "Advanced (JSON)" expander is
|
||||
* kept for power users / config the form doesn't cover. Changes are applied
|
||||
* live to the node (the global Save persists them).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Cfg = Record<string, any>;
|
||||
|
||||
interface Props {
|
||||
nodeType: string;
|
||||
config: Cfg;
|
||||
onChange: (next: Cfg) => void;
|
||||
}
|
||||
|
||||
const field = 'w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm';
|
||||
const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1';
|
||||
|
||||
const ACTIONS = [
|
||||
['queue_payment_check', 'Send payment-check email (dunning gate)'],
|
||||
['send_email', 'Send email'],
|
||||
['reserve_date', 'Reserve the event date'],
|
||||
['prepare_quote', 'Prepare a quote (draft)'],
|
||||
['prepare_contract', 'Prepare a contract (draft)'],
|
||||
['prepare_invoice', 'Prepare an invoice (draft)'],
|
||||
['prepare_event', 'Create an event (draft)'],
|
||||
['prepare_gallery', 'Create a gallery (draft)'],
|
||||
['send_document', 'Send the document'],
|
||||
['webhook', 'Call a webhook'],
|
||||
['noop', 'Do nothing'],
|
||||
];
|
||||
const CONDITIONS = [
|
||||
['invoice_paid', 'Invoice is paid'],
|
||||
['expr', 'Compare a field'],
|
||||
['always', 'Always → yes'],
|
||||
['never', 'Never → no'],
|
||||
];
|
||||
const WAIT_ANCHORS = [
|
||||
['dueDate', 'the invoice due date'],
|
||||
['issueDate', 'the invoice date'],
|
||||
['eventDate', 'the event date'],
|
||||
];
|
||||
const OPS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'truthy', 'falsy'];
|
||||
|
||||
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div><label className={lbl}>{label}</label>{children}</div>
|
||||
);
|
||||
|
||||
export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [jsonText, setJsonText] = useState(JSON.stringify(config || {}, null, 2));
|
||||
const [jsonErr, setJsonErr] = useState<string | null>(null);
|
||||
|
||||
const set = (patch: Cfg) => onChange({ ...config, ...patch });
|
||||
const num = (v: string) => (v === '' ? undefined : Number(v));
|
||||
|
||||
const applyJson = (text: string) => {
|
||||
setJsonText(text);
|
||||
try { onChange(JSON.parse(text || '{}')); setJsonErr(null); }
|
||||
catch (e) { setJsonErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); }
|
||||
};
|
||||
|
||||
const waitMode = config.untilVar ? 'until' : 'delay';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{nodeType === 'trigger' && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.triggerHint', 'The trigger is set in the toolbar above (When …).')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(nodeType === 'action' || nodeType === 'webhook') && (
|
||||
<Row label={t('workflows.editor.actionLabel', 'Action')}>
|
||||
<select className={field} value={config.action || (nodeType === 'webhook' ? 'webhook' : 'noop')} onChange={(e) => set({ action: e.target.value })}>
|
||||
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{nodeType === 'action' && config.action === 'send_email' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.recipient', 'Recipient')}>
|
||||
<select className={field} value={config.recipientClass || 'customer'} onChange={(e) => set({ recipientClass: e.target.value })}>
|
||||
<option value="customer">{t('workflows.editor.recipientCustomer', 'Customer (respects business hours)')}</option>
|
||||
<option value="admin">{t('workflows.editor.recipientAdmin', 'Admin (sent immediately)')}</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.emailTemplate', 'Email template key')}>
|
||||
<input className={field} value={config.emailType || ''} onChange={(e) => set({ emailType: e.target.value })} placeholder="invoice_reminder" />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
|
||||
<Row label={t('workflows.editor.webhookUrl', 'Webhook URL')}>
|
||||
<input className={field} value={config.url || ''} onChange={(e) => set({ url: e.target.value })} placeholder="https://…" />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{(nodeType === 'condition' || nodeType === 'branch') && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.condition', 'Condition')}>
|
||||
<select className={field} value={config.condition || 'expr'} onChange={(e) => set({ condition: e.target.value })}>
|
||||
{CONDITIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
{config.condition === 'expr' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.exprField', 'Field')}>
|
||||
<input className={field} value={config.field || ''} onChange={(e) => set({ field: e.target.value })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.exprOp', 'Operator')}>
|
||||
<select className={field} value={config.op || 'truthy'} onChange={(e) => set({ op: e.target.value })}>
|
||||
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
{!['truthy', 'falsy'].includes(config.op) && (
|
||||
<Row label={t('workflows.editor.exprValue', 'Value')}>
|
||||
<input className={field} value={config.value ?? ''} onChange={(e) => set({ value: e.target.value })} />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.conditionHint', 'Routes to the “yes” edge when true, “no” when false.')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nodeType === 'wait' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.waitType', 'Wait')}>
|
||||
<select
|
||||
className={field}
|
||||
value={waitMode}
|
||||
onChange={(e) => (e.target.value === 'until'
|
||||
? onChange({ untilVar: 'dueDate' })
|
||||
: onChange({ delayDays: config.delayDays || 1 }))}
|
||||
>
|
||||
<option value="until">{t('workflows.editor.waitUntil', 'Until a date')}</option>
|
||||
<option value="delay">{t('workflows.editor.waitDelay', 'A fixed delay')}</option>
|
||||
</select>
|
||||
</Row>
|
||||
{waitMode === 'until' ? (
|
||||
<Row label={t('workflows.editor.waitAnchor', 'Wait until')}>
|
||||
<select className={field} value={config.untilVar || 'dueDate'} onChange={(e) => onChange({ untilVar: e.target.value })}>
|
||||
{WAIT_ANCHORS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Row label={t('workflows.editor.days', 'Days')}>
|
||||
<input type="number" min={0} className={field} value={config.delayDays ?? ''} onChange={(e) => set({ delayDays: num(e.target.value) })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.hours', 'Hours')}>
|
||||
<input type="number" min={0} className={field} value={config.delayHours ?? ''} onChange={(e) => set({ delayHours: num(e.target.value) })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.minutes', 'Min')}>
|
||||
<input type="number" min={0} className={field} value={config.delayMinutes ?? ''} onChange={(e) => set({ delayMinutes: num(e.target.value) })} />
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{nodeType === 'loop' && (
|
||||
<Row label={t('workflows.editor.maxIterations', 'Repeat at most (times)')}>
|
||||
<input type="number" min={1} className={field} value={config.maxIterations ?? 3} onChange={(e) => set({ maxIterations: num(e.target.value) })} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{nodeType === 'gate' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.gatePrompt', 'Question for the admin')}>
|
||||
<textarea className={field} rows={2} value={config.prompt || ''} onChange={(e) => set({ prompt: e.target.value })} placeholder={t('workflows.editor.gatePromptPh', 'e.g. No payment received — send a reminder?') as string} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.gateTimeout', 'Auto-expire after (days, optional)')}>
|
||||
<input type="number" min={0} className={field} value={config.timeoutDays ?? ''} onChange={(e) => set({ timeoutDays: num(e.target.value) })} />
|
||||
</Row>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.gateHint', 'Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge.')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="button" className="text-xs text-neutral-500 dark:text-neutral-400 underline" onClick={() => { setJsonText(JSON.stringify(config || {}, null, 2)); setShowJson((s) => !s); }}>
|
||||
{showJson ? t('workflows.editor.hideAdvanced', 'Hide advanced (JSON)') : t('workflows.editor.showAdvanced', 'Advanced (JSON)')}
|
||||
</button>
|
||||
{showJson && (
|
||||
<div className="space-y-1">
|
||||
<textarea className={`${field} font-mono`} rows={8} value={jsonText} onChange={(e) => applyJson(e.target.value)} />
|
||||
{jsonErr && <p className="text-xs text-red-600 dark:text-red-400">{jsonErr}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,10 @@
|
||||
* Admin → Workflows → canvas editor (React Flow).
|
||||
*
|
||||
* Drag nodes from the palette, connect handle→handle (branch/gate/loop expose
|
||||
* yes/no · confirm/deny · loop/exit handles), click a node to edit its config
|
||||
* in the side panel, and Save (writes a new version; in-flight runs keep
|
||||
* theirs). The graph maps 1:1 onto workflow_nodes/workflow_edges.
|
||||
* yes/no · confirm/deny · loop/exit handles, labelled on the node), click a
|
||||
* node to edit it in the structured side panel, and Save (writes a new
|
||||
* version; in-flight runs keep theirs). The graph maps 1:1 onto
|
||||
* workflow_nodes/workflow_edges. Honours the admin light/dark theme.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -18,7 +19,9 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { ArrowLeft, Save, Trash2 } from 'lucide-react';
|
||||
import { Button, Loading } from '../../../components/common';
|
||||
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
||||
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
|
||||
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
|
||||
const TRIGGERS = [
|
||||
@@ -30,27 +33,57 @@ const COLORS: Record<string, string> = {
|
||||
trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD',
|
||||
wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780',
|
||||
};
|
||||
|
||||
const SOURCE_HANDLES: Record<string, string[]> = {
|
||||
condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'],
|
||||
};
|
||||
|
||||
function WfNode({ data }: { data: { label: string; nodeType: string } }) {
|
||||
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
queue_payment_check: 'Send payment-check email', send_email: 'Send email', reserve_date: 'Reserve the date',
|
||||
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
|
||||
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
|
||||
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
|
||||
};
|
||||
|
||||
// Human-readable label derived live from a node's type + config.
|
||||
function describeNode(type: string, config: any = {}, triggerType?: string): string {
|
||||
const c = config || {};
|
||||
switch (type) {
|
||||
case 'trigger': return triggerType || 'When…';
|
||||
case 'wait':
|
||||
if (c.untilVar) return `Wait until ${WAIT_ANCHOR_LABEL[c.untilVar] || c.untilVar}`;
|
||||
return `Wait ${[c.delayDays && `${c.delayDays}d`, c.delayHours && `${c.delayHours}h`, c.delayMinutes && `${c.delayMinutes}m`].filter(Boolean).join(' ') || '…'}`;
|
||||
case 'condition':
|
||||
case 'branch':
|
||||
if (c.condition === 'invoice_paid') return 'Invoice paid?';
|
||||
if (c.condition === 'expr') return `${c.field || 'field'} ${c.op || ''} ${c.value ?? ''}`.trim();
|
||||
if (c.condition === 'always') return 'Always';
|
||||
if (c.condition === 'never') return 'Never';
|
||||
return c.condition || 'Condition';
|
||||
case 'loop': return `Repeat ≤ ${c.maxIterations ?? 3}×`;
|
||||
case 'action': return ACTION_LABEL[c.action] || c.action || 'Action';
|
||||
case 'gate': return c.prompt ? `Ask: ${String(c.prompt).slice(0, 28)}${String(c.prompt).length > 28 ? '…' : ''}` : 'Admin confirm';
|
||||
case 'webhook': return 'Call webhook';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
function WfNode({ data }: { data: any }) {
|
||||
const handles = SOURCE_HANDLES[data.nodeType];
|
||||
const color = COLORS[data.nodeType] || '#888780';
|
||||
const label = describeNode(data.nodeType, data.config, data.triggerType);
|
||||
const pos = (i: number, n: number) => `${(100 / (n + 1)) * (i + 1)}%`;
|
||||
return (
|
||||
<div style={{ borderColor: color }} className="rounded-md border-2 bg-white dark:bg-neutral-900 px-3 py-2 min-w-[140px] text-center shadow-sm">
|
||||
<div style={{ borderColor: color }} className="relative rounded-md border-2 bg-white dark:bg-neutral-900 px-3 pt-2 pb-4 min-w-[152px] text-center shadow-sm">
|
||||
{data.nodeType !== 'trigger' && <Handle type="target" position={Position.Top} />}
|
||||
<div className="text-[10px] uppercase tracking-wide" style={{ color }}>{data.nodeType}</div>
|
||||
<div className="text-sm text-neutral-900 dark:text-neutral-100">{data.label}</div>
|
||||
{handles ? (
|
||||
handles.map((h, i) => (
|
||||
<Handle key={h} id={h} type="source" position={Position.Bottom} style={{ left: `${(100 / (handles.length + 1)) * (i + 1)}%` }}>
|
||||
</Handle>
|
||||
))
|
||||
) : (
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
)}
|
||||
<div className="text-sm text-neutral-900 dark:text-neutral-100">{label}</div>
|
||||
{handles ? handles.map((h, i) => (
|
||||
<React.Fragment key={h}>
|
||||
<span className="absolute text-[9px] text-neutral-400 dark:text-neutral-500" style={{ bottom: 3, left: pos(i, handles.length), transform: 'translateX(-50%)' }}>{h}</span>
|
||||
<Handle id={h} type="source" position={Position.Bottom} style={{ left: pos(i, handles.length) }} />
|
||||
</React.Fragment>
|
||||
)) : <Handle type="source" position={Position.Bottom} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +94,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const workflowId = Number(id);
|
||||
|
||||
@@ -76,7 +110,6 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
const [triggerType, setTriggerType] = useState('invoice.sent');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [configText, setConfigText] = useState('{}');
|
||||
const [counter, setCounter] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,7 +121,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
id: n.node_key,
|
||||
type: 'wf',
|
||||
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||
data: { label: n.node_key, nodeType: n.type, config: n.config || {} },
|
||||
data: { nodeType: n.type, config: n.config || {}, triggerType: workflow.trigger_type },
|
||||
})));
|
||||
setEdges(workflow.edges.map((e, i) => ({
|
||||
id: `e${i}`,
|
||||
@@ -99,30 +132,28 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
})));
|
||||
}, [workflow, setNodes, setEdges]);
|
||||
|
||||
// Keep the trigger node's label in sync when the trigger is changed in the toolbar.
|
||||
useEffect(() => {
|
||||
setNodes((nds) => nds.map((n) => (n.data?.nodeType === 'trigger' ? { ...n, data: { ...n.data, triggerType } } : n)));
|
||||
}, [triggerType, setNodes]);
|
||||
|
||||
const onConnect = useCallback((c: Connection) => {
|
||||
setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds));
|
||||
}, [setEdges]);
|
||||
|
||||
const addNode = (type: WorkflowNodeType) => {
|
||||
const key = type === 'trigger' ? 'trigger' : `${type}_${counter}`;
|
||||
const key = type === 'trigger' ? `trigger_${counter}` : `${type}_${counter}`;
|
||||
setCounter((c) => c + 1);
|
||||
setNodes((nds) => nds.concat({
|
||||
id: key, type: 'wf', position: { x: 120 + Math.random() * 240, y: 120 + Math.random() * 240 },
|
||||
data: { label: key, nodeType: type, config: {} },
|
||||
id: key, type: 'wf', position: { x: 140 + Math.random() * 220, y: 140 + Math.random() * 220 },
|
||||
data: { nodeType: type, config: {}, triggerType },
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]);
|
||||
useEffect(() => {
|
||||
if (selectedNode) setConfigText(JSON.stringify((selectedNode.data as any).config || {}, null, 2));
|
||||
}, [selectedNode]);
|
||||
|
||||
const applyConfig = () => {
|
||||
if (!selectedId) return;
|
||||
let parsed: Record<string, unknown>;
|
||||
try { parsed = JSON.parse(configText || '{}'); } catch (e) { toast.error(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
|
||||
setNodes((nds) => nds.map((n) => (n.id === selectedId ? { ...n, data: { ...n.data, config: parsed } } : n)));
|
||||
toast.success(t('workflows.editor.configApplied', 'Config applied (remember to Save)') as string);
|
||||
const updateNodeConfig = (nodeId: string, config: Record<string, unknown>) => {
|
||||
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, config } } : n)));
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
@@ -141,9 +172,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
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,
|
||||
})),
|
||||
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow', workflowId] });
|
||||
@@ -166,6 +195,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string}
|
||||
/>
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.editor.when', 'When')}</label>
|
||||
<select
|
||||
value={triggerType} onChange={(e) => setTriggerType(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
@@ -197,6 +227,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
<div className="flex gap-3" style={{ height: '70vh' }}>
|
||||
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<ReactFlow
|
||||
colorMode={isDark ? 'dark' : 'light'}
|
||||
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
|
||||
onNodeClick={(_, n) => setSelectedId(n.id)} fitView
|
||||
@@ -208,19 +239,20 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<div className="w-72 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-2 bg-white dark:bg-neutral-900">
|
||||
<div className="w-80 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-3 bg-white dark:bg-neutral-900 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{(selectedNode.data as any).nodeType} · {selectedNode.id}</div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{(selectedNode.data as any).nodeType} · {selectedNode.id}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={deleteSelected} aria-label={t('common.delete', 'Delete') as string}>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</Button>
|
||||
</div>
|
||||
<label className="block text-xs text-neutral-500 dark:text-neutral-400">{t('workflows.editor.config', 'Config (JSON)')}</label>
|
||||
<textarea
|
||||
value={configText} onChange={(e) => setConfigText(e.target.value)} rows={10}
|
||||
className="w-full text-xs font-mono 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"
|
||||
<NodeConfigPanel
|
||||
nodeType={(selectedNode.data as any).nodeType}
|
||||
config={(selectedNode.data as any).config || {}}
|
||||
onChange={(cfg) => updateNodeConfig(selectedNode.id, cfg)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={applyConfig}>{t('workflows.editor.applyConfig', 'Apply config')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user