Extend automations to drive and contacts with context-aware triggers, conditions, and actions. Webhooks can filter event types and scopes per domain.
492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Plus, Trash2 } from 'lucide-react'
|
|
import type { Node } from '@xyflow/react'
|
|
import { NODE_TYPE_LABELS } from '@/lib/mail-automation/node-definitions'
|
|
import {
|
|
actionTypesForDomains,
|
|
AUTOMATION_DOMAIN_LABELS,
|
|
conditionFieldsForDomains,
|
|
inferDomainsFromTriggers,
|
|
primaryDomainFromTriggers,
|
|
} from '@/lib/mail-automation/domains'
|
|
import { useAutomationDomains } from './automation-domain-context'
|
|
import { isBooleanConditionField, isLabelConditionField } from '@/lib/mail-automation/condition-helpers'
|
|
import {
|
|
actionValueSuggestionKind,
|
|
conditionValueSuggestionKind,
|
|
defaultOperatorForField,
|
|
formatConditionSummary,
|
|
operatorOptionsForConditionField,
|
|
switchFieldSuggestionKind,
|
|
} from '@/lib/mail-automation/condition-helpers'
|
|
import type {
|
|
ActionsNodeData,
|
|
ConditionNodeData,
|
|
LLMCheckNodeData,
|
|
SetVarNodeData,
|
|
SwitchNodeData,
|
|
CallRuleNodeData,
|
|
WorkflowNodeType,
|
|
ConditionField,
|
|
} from '@/lib/mail-automation/types'
|
|
import type { ApiRule } from '@/lib/api/types'
|
|
import { createEmptyAction } from '@/lib/mail-automation/defaults'
|
|
import { AutomationSuggestInput } from './automation-suggest-input'
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
interface WorkflowNodeInspectorProps {
|
|
node: Node | null
|
|
allRules: ApiRule[]
|
|
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
|
|
onDelete: (nodeId: string) => void
|
|
triggers?: import('@/lib/mail-automation/types').TriggerOrGroup
|
|
}
|
|
|
|
export function WorkflowNodeInspector({
|
|
node,
|
|
allRules,
|
|
onUpdate,
|
|
onDelete,
|
|
triggers,
|
|
}: WorkflowNodeInspectorProps) {
|
|
const contextDomains = useAutomationDomains()
|
|
const activeDomains = triggers ? inferDomainsFromTriggers(triggers) : contextDomains
|
|
const conditionFields = conditionFieldsForDomains(activeDomains)
|
|
const actionTypes = actionTypesForDomains(activeDomains)
|
|
const primaryDomain = primaryDomainFromTriggers(
|
|
triggers ?? { operator: 'or', groups: [{ operator: 'and', items: [] }] }
|
|
)
|
|
|
|
if (!node || node.type === 'start' || node.type === 'end') {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
|
Sélectionnez un nœud pour le configurer
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const type = node.type as WorkflowNodeType
|
|
const data = node.data as unknown as Record<string, unknown>
|
|
|
|
function patch(partial: Record<string, unknown>) {
|
|
onUpdate(node!.id, { ...data, ...partial })
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
<div className="flex items-start justify-between gap-2 border-b border-border px-3 py-2">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{NODE_TYPE_LABELS[type]}</p>
|
|
<p className="truncate font-mono text-[10px] text-muted-foreground">{node.id}</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
title="Supprimer le nœud"
|
|
onClick={() => onDelete(node.id)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
|
{type === 'condition' || type === 'label_check' ? (
|
|
<ConditionEditor
|
|
fields={conditionFields}
|
|
data={
|
|
type === 'label_check'
|
|
? {
|
|
field: 'label',
|
|
operator: (data as { operator?: string }).operator === 'not_has' ? 'not_has' : 'has',
|
|
value: String((data as { label?: string }).label ?? ''),
|
|
}
|
|
: (data as unknown as ConditionNodeData)
|
|
}
|
|
onChange={patch}
|
|
/>
|
|
) : null}
|
|
{type === 'switch' ? (
|
|
<SwitchEditor fields={conditionFields} data={data as unknown as SwitchNodeData} onChange={patch} />
|
|
) : null}
|
|
{type === 'llm_check' ? (
|
|
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
|
|
) : null}
|
|
{type === 'actions' ? (
|
|
<ActionsEditor
|
|
actionTypes={actionTypes}
|
|
primaryDomain={primaryDomain}
|
|
data={data as unknown as ActionsNodeData}
|
|
onChange={patch}
|
|
/>
|
|
) : null}
|
|
{type === 'set_var' ? (
|
|
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
|
|
) : null}
|
|
{type === 'call_function' || type === 'call_rule' ? (
|
|
<CallRuleEditor
|
|
data={data as unknown as CallRuleNodeData}
|
|
allRules={allRules}
|
|
kindFilter={type === 'call_function' ? 'function' : 'rule'}
|
|
onChange={patch}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConditionEditor({
|
|
fields,
|
|
data,
|
|
onChange,
|
|
}: {
|
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
|
data: ConditionNodeData
|
|
onChange: (p: Partial<ConditionNodeData>) => void
|
|
}) {
|
|
const operatorOptions = operatorOptionsForConditionField(data.field)
|
|
const valueKind = conditionValueSuggestionKind(data.field, data.operator)
|
|
const isRegex = data.operator === 'regex' || data.operator === 'not_regex'
|
|
|
|
function onFieldChange(field: ConditionField) {
|
|
onChange({
|
|
field,
|
|
operator: defaultOperatorForField(field),
|
|
value: isBooleanConditionField(field) ? 'true' : '',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DomainGroupedFieldSelect
|
|
label="Champ"
|
|
value={data.field}
|
|
fields={fields}
|
|
onChange={(v) => onFieldChange(v as ConditionField)}
|
|
/>
|
|
<FieldSelect
|
|
label={isLabelConditionField(data.field) ? 'Mode' : 'Opérateur'}
|
|
value={data.operator}
|
|
options={operatorOptions}
|
|
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
|
|
/>
|
|
<div>
|
|
<Label className="text-xs">
|
|
{isLabelConditionField(data.field) ? 'Libellé' : isRegex ? 'Expression régulière' : 'Valeur'}
|
|
</Label>
|
|
<div className="mt-1">
|
|
{isBooleanConditionField(data.field) ? (
|
|
<FieldSelect
|
|
label=""
|
|
value={data.value || 'true'}
|
|
options={[
|
|
{ value: 'true', label: 'Oui' },
|
|
{ value: 'false', label: 'Non' },
|
|
]}
|
|
onChange={(v) => onChange({ value: v })}
|
|
/>
|
|
) : (
|
|
<AutomationSuggestInput
|
|
kind={valueKind}
|
|
value={data.value}
|
|
placeholder={isRegex ? '(?i)facture|invoice' : undefined}
|
|
onChange={(value) => onChange({ value })}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SwitchEditor({
|
|
fields,
|
|
data,
|
|
onChange,
|
|
}: {
|
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
|
data: SwitchNodeData
|
|
onChange: (p: Partial<SwitchNodeData>) => void
|
|
}) {
|
|
const cases = data.cases ?? []
|
|
const fieldKind = switchFieldSuggestionKind(data.field)
|
|
|
|
return (
|
|
<>
|
|
<DomainGroupedFieldSelect
|
|
label="Champ"
|
|
value={data.field}
|
|
fields={fields}
|
|
onChange={(v) => onChange({ field: v })}
|
|
/>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">Cas de sortie</Label>
|
|
{cases.map((c, i) => (
|
|
<div key={i} className="flex gap-1">
|
|
<AutomationSuggestInput
|
|
kind={fieldKind}
|
|
className="flex-1"
|
|
placeholder="Valeur"
|
|
value={c.value}
|
|
onChange={(value) => {
|
|
const next = [...cases]
|
|
next[i] = { ...c, value }
|
|
onChange({ cases: next })
|
|
}}
|
|
/>
|
|
<Input
|
|
className="h-8 flex-1 text-xs"
|
|
placeholder="Libellé"
|
|
value={c.label ?? ''}
|
|
onChange={(e) => {
|
|
const next = [...cases]
|
|
next[i] = { ...c, label: e.target.value }
|
|
onChange({ cases: next })
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 shrink-0"
|
|
onClick={() => onChange({ cases: cases.filter((_, j) => j !== i) })}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ cases: [...cases, { value: '', label: '' }] })}>
|
|
<Plus className="mr-1 size-3" />
|
|
Ajouter un cas
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function LLMEditor({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: LLMCheckNodeData
|
|
onChange: (p: Partial<LLMCheckNodeData>) => void
|
|
}) {
|
|
return (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">Prompt</Label>
|
|
<textarea
|
|
className="mt-1 min-h-20 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
|
value={data.prompt}
|
|
onChange={(e) => onChange({ prompt: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Fournisseur (optionnel)</Label>
|
|
<Input className="mt-1 h-8 text-xs" value={data.provider ?? ''} onChange={(e) => onChange({ provider: e.target.value })} />
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ActionsEditor({
|
|
actionTypes,
|
|
primaryDomain,
|
|
data,
|
|
onChange,
|
|
}: {
|
|
actionTypes: ReturnType<typeof actionTypesForDomains>
|
|
primaryDomain: import('@/lib/mail-automation/domains').AutomationDomain
|
|
data: ActionsNodeData
|
|
onChange: (p: Partial<ActionsNodeData>) => void
|
|
}) {
|
|
const actions = data.actions ?? []
|
|
const grouped = ['universal', 'mail', 'drive', 'contacts'] as const
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{actions.map((action, i) => {
|
|
const meta = actionTypes.find((a) => a.value === action.type)
|
|
const suggestKind = actionValueSuggestionKind(action.type)
|
|
return (
|
|
<div key={i} className="space-y-1 rounded border border-border/60 p-2">
|
|
<Select value={action.type} onValueChange={(v) => {
|
|
const next = [...actions]
|
|
next[i] = { ...action, type: v as typeof action.type, value: '' }
|
|
onChange({ actions: next })
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{grouped.map((group) => {
|
|
const items = actionTypes.filter((a) => a.domain === group)
|
|
if (items.length === 0) return null
|
|
const groupLabel =
|
|
group === 'universal' ? 'Commun' : AUTOMATION_DOMAIN_LABELS[group]
|
|
return (
|
|
<SelectGroup key={group}>
|
|
<SelectLabel className="text-[10px]">{groupLabel}</SelectLabel>
|
|
{items.map((a) => (
|
|
<SelectItem key={a.value} value={a.value} className="text-xs">
|
|
{a.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
{meta?.needsValue ? (
|
|
<AutomationSuggestInput
|
|
kind={suggestKind}
|
|
value={action.value}
|
|
placeholder={meta.placeholder}
|
|
onChange={(value) => {
|
|
const next = [...actions]
|
|
next[i] = { ...action, value }
|
|
onChange({ actions: next })
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Button type="button" variant="ghost" size="sm" className="h-6 text-xs" onClick={() => onChange({ actions: actions.filter((_, j) => j !== i) })}>
|
|
Retirer
|
|
</Button>
|
|
</div>
|
|
)
|
|
})}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-full text-xs"
|
|
onClick={() => onChange({ actions: [...actions, createEmptyAction(primaryDomain)] })}
|
|
>
|
|
<Plus className="mr-1 size-3" />
|
|
Ajouter une action
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SetVarEditor({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: SetVarNodeData
|
|
onChange: (p: Partial<SetVarNodeData>) => void
|
|
}) {
|
|
return (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">Nom</Label>
|
|
<Input className="mt-1 h-8 font-mono text-xs" value={data.name} onChange={(e) => onChange({ name: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Valeur ({"{{var}}"} pour interpolation)</Label>
|
|
<Input className="mt-1 h-8 font-mono text-xs" value={data.value} onChange={(e) => onChange({ value: e.target.value })} />
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function CallRuleEditor({
|
|
data,
|
|
allRules,
|
|
kindFilter,
|
|
onChange,
|
|
}: {
|
|
data: CallRuleNodeData
|
|
allRules: ApiRule[]
|
|
kindFilter: 'rule' | 'function'
|
|
onChange: (p: Partial<CallRuleNodeData>) => void
|
|
}) {
|
|
const options = allRules.filter((r) => (r.rule_kind ?? 'rule') === kindFilter)
|
|
return (
|
|
<div>
|
|
<Label className="text-xs">{kindFilter === 'function' ? 'Fonction' : 'Règle'}</Label>
|
|
<Select value={data.rule_id || undefined} onValueChange={(v) => onChange({ rule_id: v })}>
|
|
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue placeholder="Choisir…" /></SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((r) => (
|
|
<SelectItem key={r.id} value={r.id} className="text-xs">{r.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DomainGroupedFieldSelect({
|
|
label,
|
|
value,
|
|
fields,
|
|
onChange,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
|
onChange: (v: string) => void
|
|
}) {
|
|
const domains = [...new Set(fields.map((f) => f.domain))]
|
|
return (
|
|
<div>
|
|
{label ? <Label className="text-xs">{label}</Label> : null}
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className={label ? 'mt-1 h-8 text-xs' : 'h-8 text-xs'}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{domains.map((domain) => (
|
|
<SelectGroup key={domain}>
|
|
<SelectLabel className="text-[10px]">{AUTOMATION_DOMAIN_LABELS[domain]}</SelectLabel>
|
|
{fields
|
|
.filter((f) => f.domain === domain)
|
|
.map((f) => (
|
|
<SelectItem key={f.value} value={f.value} className="text-xs">
|
|
{f.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FieldSelect({
|
|
label,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
options: { value: string; label: string }[]
|
|
onChange: (v: string) => void
|
|
}) {
|
|
return (
|
|
<div>
|
|
{label ? <Label className="text-xs">{label}</Label> : null}
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className={label ? 'mt-1 h-8 text-xs' : 'h-8 text-xs'}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
}
|