ultisuite-client/components/gmail/settings/automation/workflow-node-inspector.tsx
R3D347HR4Y 20552a34ff feat(automation): multi-domain rules and webhook scope UI
Extend automations to drive and contacts with context-aware triggers,
conditions, and actions. Webhooks can filter event types and scopes per domain.
2026-06-07 15:51:47 +02:00

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>
)
}