1"use client";
2
3
10import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
11import { inputCls } from "@/components/settings/settings-shell";
12import {
13 ACTION_LABELS,
14 ACTION_RETRY_DEFAULT,
15 ACTION_RETRY_MAX,
16 ACTION_RETRY_MIN,
17 ACTION_TIMEOUT_DEFAULT_MS,
18 ACTION_TIMEOUT_MAX_MS,
19 ACTION_TIMEOUT_MIN_MS,
20 AI_TASKS,
21 AUTOMATION_ACTION_TYPES,
22 AUTOMATION_MAX_ACTIONS,
23 NOTIFICATION_TYPES,
24 type ActionDraft,
25 type AutomationActionType
26} from "./lib";
27
28interface ActionEditorProps {
29 actions: ActionDraft[];
30 onChange: (actions: ActionDraft[]) => void;
31}
32
33const secToMs = (s: number) => Math.round(s * 1000);
34const msToSec = (ms: number) => Math.round(ms / 1000);
35
36export function ActionEditor({ actions, onChange }: ActionEditorProps) {
37 const update = (i: number, patch: Partial<ActionDraft>) => {
38 const next = [...actions];
39 next[i] = { ...next[i]!, ...patch };
40 onChange(next);
41 };
42 const setCfg = (i: number, key: string, value: string) => {
43 const a = actions[i]!;
44 update(i, { config: { ...a.config, [key]: value } });
45 };
46 const move = (i: number, dir: -1 | 1) => {
47 const j = i + dir;
48 if (j < 0 || j >= actions.length) return;
49 const next = [...actions];
50 const tmp = next[i]!;
51 next[i] = next[j]!;
52 next[j] = tmp;
53 onChange(next);
54 };
55 const remove = (i: number) => onChange(actions.filter((_a, idx) => idx !== i));
56 const add = () => {
57 if (actions.length >= AUTOMATION_MAX_ACTIONS) return;
58 onChange([
59 ...actions,
60 { type: "RUN_AI_ACTION", config: {}, retries: ACTION_RETRY_DEFAULT, timeoutMs: ACTION_TIMEOUT_DEFAULT_MS }
61 ]);
62 };
63
64 return (
65 <div className="space-y-3">
66 {actions.length === 0 && (
67 <p className="text-sm text-muted-foreground">No actions yet. An automation needs at least one action to run.</p>
68 )}
69 {actions.map((a, i) => (
70 <div key={i} className="rounded-md border border-border p-3">
71 <div className="flex items-center gap-2">
72 <span className="text-xs font-medium text-muted-foreground">{i + 1}.</span>
73 <select
74 className="rounded-md border border-input bg-background px-2 py-1 text-sm"
75 value={a.type}
76 aria-label={`Action ${i + 1} type`}
77 onChange={(e) => update(i, { type: e.target.value as AutomationActionType, config: {} })}
78 >
79 {AUTOMATION_ACTION_TYPES.map((type) => (
80 <option key={type} value={type}>
81 {ACTION_LABELS[type]}
82 </option>
83 ))}
84 </select>
85 <span className="flex-1" />
86 <button
87 type="button"
88 className="rounded-md p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
89 onClick={() => move(i, -1)}
90 disabled={i === 0}
91 aria-label={`Move action ${i + 1} up`}
92 >
93 <ArrowUp className="size-4" />
94 </button>
95 <button
96 type="button"
97 className="rounded-md p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
98 onClick={() => move(i, 1)}
99 disabled={i === actions.length - 1}
100 aria-label={`Move action ${i + 1} down`}
101 >
102 <ArrowDown className="size-4" />
103 </button>
104 <button
105 type="button"
106 className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
107 onClick={() => remove(i)}
108 aria-label={`Remove action ${i + 1}`}
109 >
110 <Trash2 className="size-4" />
111 </button>
112 </div>
113
114 <div className="mt-2.5 grid gap-2 sm:grid-cols-2">
115 {a.type === "UPDATE_RECORD" && (
116 <>
117 <Cfg label="Record id (default from_trigger)" value={a.config.recordId ?? ""} onChange={(v) => setCfg(i, "recordId", v)} />
118 <Cfg label="Property name" value={a.config.propertyName ?? ""} onChange={(v) => setCfg(i, "propertyName", v)} />
119 <Cfg label="Value" value={a.config.value ?? ""} onChange={(v) => setCfg(i, "value", v)} />
120 </>
121 )}
122 {a.type === "CHANGE_STATUS" && (
123 <>
124 <Cfg label="Record id (default from_trigger)" value={a.config.recordId ?? ""} onChange={(v) => setCfg(i, "recordId", v)} />
125 <Cfg label="Status/Select property id" value={a.config.propertyId ?? ""} onChange={(v) => setCfg(i, "propertyId", v)} />
126 <Cfg label="Option name" value={a.config.value ?? ""} onChange={(v) => setCfg(i, "value", v)} />
127 </>
128 )}
129 {a.type === "ASSIGN_USER" && (
130 <>
131 <Cfg label="Record id (default from_trigger)" value={a.config.recordId ?? ""} onChange={(v) => setCfg(i, "recordId", v)} />
132 <Cfg label="Person property id" value={a.config.propertyId ?? ""} onChange={(v) => setCfg(i, "propertyId", v)} />
133 <Cfg label="User id" value={a.config.userId ?? ""} onChange={(v) => setCfg(i, "userId", v)} />
134 </>
135 )}
136 {a.type === "CREATE_NOTIFICATION" && (
137 <>
138 <label className="block">
139 <span className="mb-1 block text-xs font-medium text-muted-foreground">Notification type</span>
140 <select className={inputCls} value={a.config.type ?? "AUTOMATION"} onChange={(e) => setCfg(i, "type", e.target.value)}>
141 {NOTIFICATION_TYPES.map((t) => (
142 <option key={t} value={t}>
143 {t}
144 </option>
145 ))}
146 </select>
147 </label>
148 <label className="block sm:col-span-2">
149 <span className="mb-1 block text-xs font-medium text-muted-foreground">Message (supports {"{{…}}"} interpolation)</span>
150 <input className={inputCls} value={a.config.body ?? ""} onChange={(e) => setCfg(i, "body", e.target.value)} />
151 </label>
152 </>
153 )}
154 {a.type === "CREATE_PAGE" && (
155 <>
156 <Cfg label="Page title" value={a.config.title ?? ""} onChange={(v) => setCfg(i, "title", v)} />
157 <Cfg label="Parent page id (optional)" value={a.config.parentPageId ?? ""} onChange={(v) => setCfg(i, "parentPageId", v)} />
158 </>
159 )}
160 {a.type === "SEND_WEBHOOK" && (
161 <>
162 <label className="block sm:col-span-2">
163 <span className="mb-1 block text-xs font-medium text-muted-foreground">URL (https only)</span>
164 <input className={inputCls} placeholder="https://example.com/hook" value={a.config.url ?? ""} onChange={(e) => setCfg(i, "url", e.target.value)} />
165 </label>
166 <label className="block">
167 <span className="mb-1 block text-xs font-medium text-muted-foreground">Headers (JSON object, optional)</span>
168 <textarea className={`${inputCls} min-h-16 font-mono text-xs`} value={a.config.headersJson ?? ""} onChange={(e) => setCfg(i, "headersJson", e.target.value)} />
169 </label>
170 <label className="block">
171 <span className="mb-1 block text-xs font-medium text-muted-foreground">Payload (JSON object, optional)</span>
172 <textarea className={`${inputCls} min-h-16 font-mono text-xs`} value={a.config.bodyTemplate ?? ""} onChange={(e) => setCfg(i, "bodyTemplate", e.target.value)} />
173 </label>
174 </>
175 )}
176 {a.type === "SEND_EMAIL" && (
177 <>
178 <Cfg label="To (email, trigger_user or automation_creator)" value={a.config.to ?? ""} onChange={(v) => setCfg(i, "to", v)} />
179 <Cfg label="Subject" value={a.config.subject ?? ""} onChange={(v) => setCfg(i, "subject", v)} />
180 <label className="block sm:col-span-2">
181 <span className="mb-1 block text-xs font-medium text-muted-foreground">Body</span>
182 <textarea className={`${inputCls} min-h-20`} value={a.config.body ?? ""} onChange={(e) => setCfg(i, "body", e.target.value)} />
183 </label>
184 </>
185 )}
186 {a.type === "CALL_INTEGRATION" && (
187 <>
188 <Cfg label="Integration id" value={a.config.integrationId ?? ""} onChange={(v) => setCfg(i, "integrationId", v)} />
189 <Cfg label="Operation" value={a.config.operation ?? ""} onChange={(v) => setCfg(i, "operation", v)} />
190 <Cfg label="Params (optional)" value={a.config.params ?? ""} onChange={(v) => setCfg(i, "params", v)} />
191 </>
192 )}
193 {a.type === "RUN_AI_ACTION" && (
194 <>
195 <label className="block">
196 <span className="mb-1 block text-xs font-medium text-muted-foreground">Task</span>
197 <select className={inputCls} value={a.config.task ?? "summarize"} onChange={(e) => setCfg(i, "task", e.target.value)}>
198 {AI_TASKS.map((t) => (
199 <option key={t} value={t}>
200 {t}
201 </option>
202 ))}
203 </select>
204 </label>
205 <label className="block sm:col-span-2">
206 <span className="mb-1 block text-xs font-medium text-muted-foreground">Prompt (supports {"{{…}}"} interpolation)</span>
207 <textarea className={`${inputCls} min-h-20`} value={a.config.prompt ?? ""} onChange={(e) => setCfg(i, "prompt", e.target.value)} />
208 </label>
209 </>
210 )}
211 </div>
212
213 <div className="mt-2.5 flex flex-wrap items-center gap-4 border-t border-border pt-2">
214 <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
215 Retries
216 <input
217 type="number"
218 min={ACTION_RETRY_MIN}
219 max={ACTION_RETRY_MAX}
220 className="w-16 rounded-md border border-input bg-background px-2 py-1 text-sm"
221 value={a.retries}
222 onChange={(e) => {
223 const n = Number(e.target.value);
224 update(i, { retries: Number.isFinite(n) ? Math.min(ACTION_RETRY_MAX, Math.max(ACTION_RETRY_MIN, Math.round(n))) : ACTION_RETRY_DEFAULT });
225 }}
226 />
227 </label>
228 <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
229 Timeout (s)
230 <input
231 type="number"
232 min={msToSec(ACTION_TIMEOUT_MIN_MS)}
233 max={msToSec(ACTION_TIMEOUT_MAX_MS)}
234 step={1}
235 className="w-20 rounded-md border border-input bg-background px-2 py-1 text-sm"
236 value={msToSec(a.timeoutMs)}
237 onChange={(e) => {
238 const n = Number(e.target.value);
239 update(i, { timeoutMs: Number.isFinite(n) ? Math.min(ACTION_TIMEOUT_MAX_MS, Math.max(ACTION_TIMEOUT_MIN_MS, secToMs(n))) : ACTION_TIMEOUT_DEFAULT_MS });
240 }}
241 />
242 </label>
243 </div>
244 </div>
245 ))}
246
247 <button
248 type="button"
249 data-testid="automation-action-add"
250 className="inline-flex items-center gap-1.5 rounded-md border border-dashed px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
251 onClick={add}
252 >
253 <Plus className="size-4" /> Add action
254 </button>
255 </div>
256 );
257}
258
259function Cfg({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
260 return (
261 <label className="block">
262 <span className="mb-1 block text-xs font-medium text-muted-foreground">{label}</span>
263 <input className={inputCls} value={value} onChange={(e) => onChange(e.target.value)} />
264 </label>
265 );
266}
267