Extend automations to drive and contacts with context-aware triggers, conditions, and actions. Webhooks can filter event types and scopes per domain.
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import {
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
addEdge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
useReactFlow,
|
|
type Connection,
|
|
type Node,
|
|
} from '@xyflow/react'
|
|
import '@xyflow/react/dist/style.css'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Plus, Variable } from 'lucide-react'
|
|
import { workflowNodeTypes } from './workflow-nodes'
|
|
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
|
|
import { WorkflowNodeInspector } from './workflow-node-inspector'
|
|
import { AutomationDomainProvider } from './automation-domain-context'
|
|
import {
|
|
PALETTE_NODE_TYPES,
|
|
NODE_TYPE_LABELS,
|
|
NODE_TYPE_DESCRIPTIONS,
|
|
} from '@/lib/mail-automation/node-definitions'
|
|
import {
|
|
createFlowNode,
|
|
flowToWorkflow,
|
|
workflowEdgesToFlow,
|
|
workflowNodesToFlow,
|
|
} from '@/lib/mail-automation/workflow-flow'
|
|
import type { ApiRule } from '@/lib/api/types'
|
|
import type { ExecVariable, RuleEditorState, WorkflowNodeType } from '@/lib/mail-automation/types'
|
|
import { nextNodeId } from '@/lib/mail-automation/defaults'
|
|
|
|
interface RuleWorkflowEditorProps {
|
|
state: RuleEditorState
|
|
allRules: ApiRule[]
|
|
onChange: (state: RuleEditorState) => void
|
|
readOnly?: boolean
|
|
}
|
|
|
|
export function RuleWorkflowEditor(props: RuleWorkflowEditorProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<RuleWorkflowEditorInner {...props} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|
|
|
|
function RuleWorkflowEditorInner({
|
|
state,
|
|
allRules,
|
|
onChange,
|
|
readOnly,
|
|
}: RuleWorkflowEditorProps) {
|
|
const { setCenter, getNodes } = useReactFlow()
|
|
const initialNodes = useMemo(() => workflowNodesToFlow(state.workflow.nodes), [state.workflow.nodes])
|
|
const initialEdges = useMemo(() => workflowEdgesToFlow(state.workflow.edges), [state.workflow.edges])
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
|
|
const selectedNode = nodes.find((n) => n.id === selectedId) ?? null
|
|
|
|
const syncWorkflow = useCallback(
|
|
(nextNodes: Node[], nextEdges: typeof edges) => {
|
|
onChange({
|
|
...state,
|
|
workflow: flowToWorkflow(
|
|
state.rule_kind,
|
|
state.workflow.triggers,
|
|
state.workflow.variables,
|
|
nextNodes,
|
|
nextEdges
|
|
),
|
|
})
|
|
},
|
|
[onChange, state]
|
|
)
|
|
|
|
const applySelection = useCallback(
|
|
(nodeId: string | null) => {
|
|
setSelectedId(nodeId)
|
|
setNodes((nds) =>
|
|
nds.map((n) => ({
|
|
...n,
|
|
selected: nodeId !== null && n.id === nodeId,
|
|
}))
|
|
)
|
|
},
|
|
[setNodes]
|
|
)
|
|
|
|
const focusNode = useCallback(
|
|
(nodeId: string) => {
|
|
requestAnimationFrame(() => {
|
|
const node = getNodes().find((n) => n.id === nodeId)
|
|
if (!node) return
|
|
const w = node.measured?.width ?? 200
|
|
const h = node.measured?.height ?? 80
|
|
setCenter(node.position.x + w / 2, node.position.y + h / 2, {
|
|
zoom: 1,
|
|
duration: 300,
|
|
})
|
|
})
|
|
},
|
|
[getNodes, setCenter]
|
|
)
|
|
|
|
const onConnect = useCallback(
|
|
(connection: Connection) => {
|
|
if (readOnly) return
|
|
const next = addEdge({ ...connection, animated: true, id: nextNodeId('e') }, edges)
|
|
setEdges(next)
|
|
syncWorkflow(nodes, next)
|
|
},
|
|
[edges, nodes, readOnly, setEdges, syncWorkflow]
|
|
)
|
|
|
|
const onNodeDragStop = useCallback(() => {
|
|
syncWorkflow(nodes, edges)
|
|
}, [edges, nodes, syncWorkflow])
|
|
|
|
function addNode(type: WorkflowNodeType) {
|
|
if (readOnly || type === 'start' || type === 'end') return
|
|
const last = nodes[nodes.length - 1]
|
|
const x = last ? last.position.x + 220 : 300
|
|
const y = last ? last.position.y : 200
|
|
const node = createFlowNode(type, { x, y })
|
|
const nextNodes = nodes.map((n) => ({ ...n, selected: false })).concat({ ...node, selected: true })
|
|
setNodes(nextNodes)
|
|
syncWorkflow(nextNodes, edges)
|
|
applySelection(node.id)
|
|
setTimeout(() => focusNode(node.id), 50)
|
|
}
|
|
|
|
function updateNodeData(nodeId: string, data: Record<string, unknown>) {
|
|
const nextNodes = nodes.map((n) => (n.id === nodeId ? { ...n, data } : n))
|
|
setNodes(nextNodes)
|
|
syncWorkflow(nextNodes, edges)
|
|
}
|
|
|
|
function deleteNode(nodeId: string) {
|
|
const node = nodes.find((n) => n.id === nodeId)
|
|
if (!node || node.type === 'start' || node.type === 'end') return
|
|
const nextNodes = nodes.filter((n) => n.id !== nodeId)
|
|
const nextEdges = edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
|
|
setNodes(nextNodes)
|
|
setEdges(nextEdges)
|
|
syncWorkflow(nextNodes, nextEdges)
|
|
applySelection(null)
|
|
}
|
|
|
|
function updateVariables(variables: ExecVariable[]) {
|
|
onChange({
|
|
...state,
|
|
workflow: { ...state.workflow, variables },
|
|
})
|
|
}
|
|
|
|
return (
|
|
<AutomationDomainProvider triggers={state.workflow.triggers}>
|
|
<div className="flex min-h-[520px] flex-col gap-3 lg:flex-row">
|
|
<aside className="w-full shrink-0 space-y-2 lg:w-52">
|
|
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
|
|
<div className="space-y-1">
|
|
{PALETTE_NODE_TYPES.map((type) => (
|
|
<Button
|
|
key={type}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-auto w-full min-w-0 justify-start px-2 py-1.5 text-left text-xs"
|
|
disabled={readOnly}
|
|
onClick={() => addNode(type)}
|
|
title={NODE_TYPE_DESCRIPTIONS[type]}
|
|
>
|
|
<Plus className="mr-1.5 mt-0.5 size-3 shrink-0 self-start" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block font-medium leading-tight">{NODE_TYPE_LABELS[type]}</span>
|
|
<span className="mt-0.5 block text-wrap break-words text-[10px] leading-snug text-muted-foreground">
|
|
{NODE_TYPE_DESCRIPTIONS[type]}
|
|
</span>
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<VariablesPanel variables={state.workflow.variables} disabled={readOnly} onChange={updateVariables} />
|
|
</aside>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
<div>
|
|
<Label className="text-xs">Nom</Label>
|
|
<Input
|
|
className="mt-1 h-8 text-xs"
|
|
value={state.name}
|
|
disabled={readOnly}
|
|
onChange={(e) => onChange({ ...state, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Priorité</Label>
|
|
<Input
|
|
type="number"
|
|
className="mt-1 h-8 text-xs"
|
|
value={state.priority}
|
|
disabled={readOnly}
|
|
onChange={(e) => onChange({ ...state, priority: Number(e.target.value) || 0 })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end gap-2 pb-1">
|
|
<Switch
|
|
checked={state.is_active}
|
|
disabled={readOnly}
|
|
onCheckedChange={(v) => onChange({ ...state, is_active: v })}
|
|
/>
|
|
<Label className="text-xs">Active</Label>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Type</Label>
|
|
<Input className="mt-1 h-8 text-xs" value={state.rule_kind === 'function' ? 'Fonction' : 'Règle'} readOnly />
|
|
</div>
|
|
</div>
|
|
|
|
{state.rule_kind === 'rule' ? (
|
|
<WorkflowTriggersPanel
|
|
triggers={state.workflow.triggers}
|
|
disabled={readOnly}
|
|
onChange={(triggers) =>
|
|
onChange({ ...state, workflow: { ...state.workflow, triggers } })
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
<div className="h-[420px] overflow-hidden rounded-lg border border-border">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
nodeTypes={workflowNodeTypes}
|
|
onNodesChange={readOnly ? undefined : onNodesChange}
|
|
onEdgesChange={readOnly ? undefined : onEdgesChange}
|
|
onConnect={onConnect}
|
|
onNodeDragStop={onNodeDragStop}
|
|
onNodeClick={(_, n) => applySelection(n.id)}
|
|
onPaneClick={() => applySelection(null)}
|
|
fitView
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background gap={16} />
|
|
<Controls showInteractive={!readOnly} />
|
|
<MiniMap pannable zoomable />
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="w-full shrink-0 overflow-hidden rounded-lg border border-border lg:w-64">
|
|
<WorkflowNodeInspector
|
|
node={selectedNode}
|
|
allRules={allRules}
|
|
triggers={state.workflow.triggers}
|
|
onUpdate={updateNodeData}
|
|
onDelete={deleteNode}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
</AutomationDomainProvider>
|
|
)
|
|
}
|
|
|
|
function VariablesPanel({
|
|
variables,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
variables: ExecVariable[]
|
|
onChange: (v: ExecVariable[]) => void
|
|
disabled?: boolean
|
|
}) {
|
|
return (
|
|
<div className="mt-4 space-y-2 rounded-lg border border-border/60 p-2">
|
|
<div className="flex items-center gap-1 text-xs font-medium">
|
|
<Variable className="size-3.5" />
|
|
Variables d'exécution
|
|
</div>
|
|
{variables.map((v, i) => (
|
|
<div key={i} className="flex gap-1">
|
|
<Input
|
|
className="h-7 flex-1 font-mono text-[10px]"
|
|
placeholder="nom"
|
|
value={v.name}
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
const next = [...variables]
|
|
next[i] = { ...v, name: e.target.value }
|
|
onChange(next)
|
|
}}
|
|
/>
|
|
<Input
|
|
className="h-7 flex-1 text-[10px]"
|
|
placeholder="défaut"
|
|
value={v.default ?? ''}
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
const next = [...variables]
|
|
next[i] = { ...v, default: e.target.value }
|
|
onChange(next)
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-full text-xs"
|
|
disabled={disabled}
|
|
onClick={() => onChange([...variables, { name: '', type: 'string', default: '' }])}
|
|
>
|
|
<Plus className="mr-1 size-3" />
|
|
Variable
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|