1"use client";
2
3
14import { useState } from "react";
15import { Plus, Trash2 } from "lucide-react";
16import { z } from "zod";
17import { inputCls } from "@/components/settings/settings-shell";
18import { CONDITION_OPERATORS, type ConditionDraft, type ConditionOperator } from "./lib";
19
20
21const PREDICATE = z
22 .object({
23 field: z.object({ ref: z.enum(["event", "property"]), name: z.string().min(1).max(200) }).strict(),
24 operator: z.enum(["eq", "neq", "contains", "not_contains", "starts_with", "ends_with", "gt", "gte", "lt", "lte", "in", "not_in", "is_empty", "is_not_empty"]),
25 value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]).optional()
26 })
27 .strict();
28
29export const CONDITION_TREE: z.ZodType<unknown> = z.lazy(() =>
30 z.union([
31 z
32 .object({
33 op: z.enum(["AND", "OR"]),
34 children: z.array(CONDITION_TREE).min(1).max(100)
35 })
36 .strict(),
37 z.object({ op: z.literal("NOT"), child: CONDITION_TREE }).strict(),
38 PREDICATE
39 ])
40);
41
42
43export function validateConditionJson(raw: string): string | null {
44 let parsed: unknown;
45 try {
46 parsed = JSON.parse(raw);
47 } catch (e) {
48 return `Invalid JSON: ${(e as Error).message}`;
49 }
50 const r = CONDITION_TREE.safeParse(parsed);
51 if (r.success) return null;
52 const first = r.error.issues[0];
53 return first ? `Invalid condition: ${first.path.join(".") || "condition"} — ${first.message}` : "Invalid condition.";
54}
55
56interface ConditionEditorProps {
57 draft: ConditionDraft;
58 onChange: (d: ConditionDraft) => void;
59}
60
61export function ConditionEditor({ draft, onChange }: ConditionEditorProps) {
62 const [mode, setMode] = useState<"simple" | "json">(draft.json.trim() ? "json" : "simple");
63 const [localError, setLocalError] = useState<string | null>(null);
64
65 const simpleMode = mode === "simple";
66 const showValue = simpleMode && draft.operator !== "exists";
67
68 const setField = (patch: Partial<ConditionDraft>) =>
69 onChange({ ...draft, ...(simpleMode ? { json: "" } : {}), ...patch });
70
71 const switchMode = (m: "simple" | "json") => {
72 setLocalError(null);
73
74
75 onChange({ ...draft, ...(m === "simple" ? { json: "" } : {}) });
76 setMode(m);
77 };
78
79 const toJsonText = () => {
80 const trimmed = draft.json.trim();
81 if (trimmed) return trimmed;
82 const opMap: Record<ConditionOperator, string> = {
83 "=": "eq",
84 "!=": "neq",
85 ">": "gt",
86 "<": "lt",
87 contains: "contains",
88 exists: "is_not_empty"
89 };
90 const predicate: Record<string, unknown> = {
91 field: { ref: draft.fieldRef, name: draft.fieldName || "property" },
92 operator: opMap[draft.operator]
93 };
94 if (draft.operator !== "exists" && draft.value.trim() !== "") predicate.value = draft.value;
95 return JSON.stringify(predicate, null, 2);
96 };
97
98 return (
99 <div className="space-y-3">
100 {!draft.hasCondition ? (
101 <button
102 type="button"
103 data-testid="automation-condition-add"
104 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"
105 onClick={() => setField({ hasCondition: true })}
106 >
107 <Plus className="size-4" /> Add condition
108 </button>
109 ) : (
110 <>
111 <div className="flex items-center gap-1.5 text-xs">
112 <button
113 type="button"
114 className={`rounded-md border px-2 py-1 ${simpleMode ? "border-primary text-foreground" : "text-muted-foreground"}`}
115 onClick={() => switchMode("simple")}
116 >
117 Simple
118 </button>
119 <button
120 type="button"
121 className={`rounded-md border px-2 py-1 ${!simpleMode ? "border-primary text-foreground" : "text-muted-foreground"}`}
122 onClick={() => switchMode("json")}
123 >
124 JSON
125 </button>
126 <span className="flex-1" />
127 <button
128 type="button"
129 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground hover:text-destructive"
130 onClick={() => setField({ hasCondition: false, json: "" })}
131 >
132 <Trash2 className="size-3.5" /> No condition
133 </button>
134 </div>
135
136 {simpleMode ? (
137 <div className="grid gap-2 sm:grid-cols-[1fr_1fr_100px_1fr]">
138 <label className="block">
139 <span className="mb-1 block text-xs font-medium text-muted-foreground">Field</span>
140 <div className="flex gap-1">
141 <select
142 className="w-28 shrink-0 rounded-md border border-input bg-background px-1.5 py-1.5 text-sm"
143 value={draft.fieldRef}
144 aria-label="Condition field source"
145 onChange={(e) => setField({ fieldRef: e.target.value as "property" | "event" })}
146 >
147 <option value="property">Record property</option>
148 <option value="event">Event field</option>
149 </select>
150 <input
151 className={`${inputCls} min-w-0 flex-1`}
152 placeholder="e.g. Status"
153 value={draft.fieldName}
154 onChange={(e) => setField({ fieldName: e.target.value })}
155 />
156 </div>
157 </label>
158 <label className="block">
159 <span className="mb-1 block text-xs font-medium text-muted-foreground">Operator</span>
160 <select
161 className={`${inputCls} rounded-md`}
162 value={draft.operator}
163 aria-label="Condition operator"
164 onChange={(e) => setField({ operator: e.target.value as ConditionOperator })}
165 >
166 {CONDITION_OPERATORS.map((op) => (
167 <option key={op} value={op}>
168 {op === "exists" ? "exists" : op}
169 </option>
170 ))}
171 </select>
172 </label>
173 <label className="block sm:col-span-2">
174 <span className="mb-1 block text-xs font-medium text-muted-foreground">Value{showValue ? "" : " (not needed)"}</span>
175 <input
176 className={inputCls}
177 placeholder={showValue ? "e.g. In progress" : ""}
178 disabled={!showValue}
179 value={draft.value}
180 onChange={(e) => setField({ value: e.target.value })}
181 />
182 </label>
183 </div>
184 ) : (
185 <div>
186 <textarea
187 className={`${inputCls} min-h-36 font-mono text-xs`}
188 value={draft.json.trim() ? draft.json : toJsonText()}
189 placeholder='{"field":{"ref":"property","name":"Status"},"operator":"eq","value":"In progress"}'
190 onChange={(e) => {
191 const v = e.target.value;
192 setLocalError(v.trim() ? validateConditionJson(v) : null);
193 setField({ json: v });
194 }}
195 aria-label="Condition JSON"
196 />
197 {localError && <p className="mt-1 text-xs text-destructive">{localError}</p>}
198 </div>
199 )}
200 </>
201 )}
202 </div>
203 );
204}
205