ultisuite-client/components/gmail/settings/automation/rule-workflow-editor.tsx
2026-05-25 13:52:40 +02:00

331 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 {
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 (
<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}
onUpdate={updateNodeData}
onDelete={deleteNode}
/>
</aside>
</div>
)
}
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&apos;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>
)
}