1"use client";
2
3
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18import { Check, Loader2, Save, X } from "lucide-react";
19import { cn } from "@/lib/utils";
20import {
21 DatabaseProperty,
22 FormulaValidationResult,
23 useUpdateProperty,
24 useValidateFormula
25} from "@/hooks/use-database";
26
27const FUNCTIONS: { name: string; signature: string; desc: string }[] = [
28 { name: "if", signature: "if(cond, a, b)", desc: "Return a if cond, else b" },
29 { name: "and", signature: "and(a, b, …)", desc: "Logical AND" },
30 { name: "or", signature: "or(a, b, …)", desc: "Logical OR" },
31 { name: "not", signature: "not(a)", desc: "Logical NOT" },
32 { name: "plus", signature: "plus(a, b, …)", desc: "Numeric sum" },
33 { name: "minus", signature: "minus(a, b)", desc: "Numeric difference" },
34 { name: "times", signature: "times(a, b)", desc: "Numeric product" },
35 { name: "divide", signature: "divide(a, b)", desc: "Numeric quotient (0 → null)" },
36 { name: "mod", signature: "mod(a, b)", desc: "Remainder" },
37 { name: "abs", signature: "abs(n)", desc: "Absolute value" },
38 { name: "round", signature: "round(n, d=0)", desc: "Round to d decimals" },
39 { name: "min", signature: "min(…)", desc: "Smallest value" },
40 { name: "max", signature: "max(…)", desc: "Largest value" },
41 { name: "power", signature: "power(n, e)", desc: "n raised to e" },
42 { name: "sqrt", signature: "sqrt(n)", desc: "Square root" },
43 { name: "concat", signature: "concat(a, b, …)", desc: "Concatenate as text" },
44 { name: "upper", signature: "upper(s)", desc: "Uppercase" },
45 { name: "lower", signature: "lower(s)", desc: "Lowercase" },
46 { name: "len", signature: "len(s)", desc: "String length" },
47 { name: "contains", signature: "contains(s, sub)", desc: "Substring test" },
48 { name: "trim", signature: "trim(s)", desc: "Trim whitespace" },
49 { name: "now", signature: "now()", desc: "Current date/time" },
50 { name: "today", signature: "today()", desc: "Current date" },
51 { name: "date", signature: "date(iso)", desc: "Parse an ISO date" },
52 { name: "years", signature: "years(a, b)", desc: "Year difference" },
53 { name: "months", signature: "months(a, b)", desc: "Month difference" },
54 { name: "days", signature: "days(a, b)", desc: "Day difference" },
55 { name: "format", signature: "format(v, f)", desc: "Format per f (currency, percent…)" }
56];
57
58export interface FormulaEditorProps {
59 databaseId: string;
60 property: DatabaseProperty;
61 initialExpression: string;
62
63 sampleRecordIds: string[];
64 onClose: () => void;
65 onSaved: () => void;
66}
67
68export function FormulaEditor({
69 databaseId,
70 property,
71 initialExpression,
72 sampleRecordIds,
73 onClose,
74 onSaved
75}: FormulaEditorProps) {
76 const [expr, setExpr] = useState(initialExpression);
77 const [validation, setValidation] = useState<FormulaValidationResult | null>(null);
78 const [saving, setSaving] = useState(false);
79 const [error, setError] = useState<string | null>(null);
80 const validate = useValidateFormula(databaseId);
81 const updateProperty = useUpdateProperty(databaseId);
82 const inputRef = useRef<HTMLTextAreaElement>(null);
83 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
84
85
86 const scheduleValidate = useCallback(
87 (value: string) => {
88 if (timer.current) clearTimeout(timer.current);
89 if (!value.trim()) {
90 setValidation(null);
91 return;
92 }
93 timer.current = setTimeout(() => {
94 validate.mutate({ expression: value, sampleRecordIds: sampleRecordIds.slice(0, 5) }, {
95 onSuccess: (res) => {
96 setValidation(res);
97 setError(null);
98 },
99 onError: (e) => {
100 setValidation(null);
101 setError((e as Error).message);
102 }
103 });
104 }, 400);
105 },
106 [validate, sampleRecordIds]
107 );
108
109 useEffect(() => {
110 inputRef.current?.focus();
111 return () => {
112 if (timer.current) clearTimeout(timer.current);
113 };
114 }, []);
115
116
117 useEffect(() => {
118 const onKey = (e: KeyboardEvent) => {
119 if (e.key === "Escape") onClose();
120 };
121 document.addEventListener("keydown", onKey);
122 return () => document.removeEventListener("keydown", onKey);
123 }, [onClose]);
124
125 const insertFunction = (name: string) => {
126 const el = inputRef.current;
127 const token = `${name}(`;
128 if (!el) {
129 setExpr((e) => e + token);
130 return;
131 }
132 const start = el.selectionStart ?? expr.length;
133 const end = el.selectionEnd ?? expr.length;
134 const next = expr.slice(0, start) + token + expr.slice(end);
135 setExpr(next);
136 requestAnimationFrame(() => {
137 el.focus();
138 el.setSelectionRange(start + token.length - 1, start + token.length - 1);
139 });
140 scheduleValidate(next);
141 };
142
143 const canSave = expr.trim().length > 0 && validation?.ok === true;
144 const functionIndex = useMemo(() => FUNCTIONS.map((f) => f.name.toLowerCase()).sort(), []);
145
146 return (
147 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-label="Formula editor">
148 <div className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border bg-background shadow-xl">
149 {}
150 <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
151 <span className="text-sm font-medium">
152 Formula: <span className="text-primary">{property.name}</span>
153 </span>
154 <div className="ml-auto flex items-center gap-1">
155 <button
156 type="button"
157 className="inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
158 disabled={!canSave || saving}
159 onClick={() => {
160 setSaving(true);
161 updateProperty.mutate(
162 { propertyId: property.id, config: { ...property.config, expression: expr } },
163 {
164 onSuccess: () => onSaved(),
165 onError: (e) => {
166 setError((e as Error).message);
167 setSaving(false);
168 }
169 }
170 );
171 }}
172 >
173 {saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
174 Save
175 </button>
176 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={onClose} aria-label="Close">
177 <X className="size-4" />
178 </button>
179 </div>
180 </div>
181
182 <div className="flex min-h-0 flex-1">
183 {}
184 <aside className="hidden w-64 shrink-0 flex-col border-r border-border sm:flex">
185 <div className="border-b border-border px-3 py-2">
186 <input
187 className="w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
188 placeholder="Filter functions…"
189 aria-label="Filter functions"
190 onKeyDown={(e) => {
191 const list = functionIndex.filter((n) => n.startsWith((e.currentTarget.value || "").toLowerCase()));
192 if (e.key === "Enter" && list.length > 0) insertFunction(FUNCTIONS.find((f) => f.name === list[0])!.name);
193 }}
194 />
195 </div>
196 <ul className="min-h-0 flex-1 overflow-y-auto p-2 text-xs">
197 {FUNCTIONS.map((f) => (
198 <li key={f.name}>
199 <button
200 type="button"
201 className="w-full rounded px-2 py-1 text-left hover:bg-muted"
202 onClick={() => insertFunction(f.name)}
203 title={f.desc}
204 >
205 <span className="font-mono text-primary">{f.name}</span>
206 <span className="ml-1 text-muted-foreground">{f.signature.replace(`${f.name}`, "")}</span>
207 <span className="mt-0.5 block truncate text-[10px] text-muted-foreground">{f.desc}</span>
208 </button>
209 </li>
210 ))}
211 </ul>
212 </aside>
213
214 {}
215 <div className="flex min-w-0 flex-1 flex-col p-4">
216 <label className="mb-1 text-xs text-muted-foreground">Expression</label>
217 <textarea
218 ref={inputRef}
219 className="h-28 w-full resize-none rounded-md border border-input bg-background p-2 font-mono text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
220 placeholder="e.g. if(status = "Done", plus(price, 10), price)"
221 value={expr}
222 spellCheck={false}
223 onChange={(e) => {
224 setExpr(e.target.value);
225 scheduleValidate(e.target.value);
226 }}
227 />
228
229 {}
230 <div className="mt-2 min-h-6">
231 {validate.isPending && (
232 <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
233 <Loader2 className="size-3 animate-spin" /> Checking expression…
234 </span>
235 )}
236 {validation?.ok && (
237 <span className="inline-flex items-center gap-1 text-xs text-emerald-600">
238 <Check className="size-3.5" /> Valid — returns {validation.returnType ?? "value"}
239 </span>
240 )}
241 {!validation?.ok && validation?.error && (
242 <span className="text-xs text-destructive">
243 {validation.error}
244 {validation.position != null && (
245 <span className="ml-1 text-muted-foreground">(position {validation.position})</span>
246 )}
247 </span>
248 )}
249 {error && <span className="text-xs text-destructive">{error}</span>}
250 </div>
251
252 {}
253 {validation?.ok && validation.samples && validation.samples.length > 0 && (
254 <div className="mt-2 rounded-md border border-border">
255 <p className="border-b border-border px-2 py-1 text-[11px] font-medium text-muted-foreground">Sample values</p>
256 <ul className="max-h-32 overflow-y-auto text-xs">
257 {validation.samples.map((s, i) => (
258 <li key={s.recordId} className="flex items-center gap-2 border-b border-border/40 px-2 py-1 last:border-b-0">
259 <span className="w-16 truncate text-muted-foreground" title={s.recordId}>
260 …{s.recordId.slice(-6)}
261 </span>
262 {s.error ? (
263 <span className="text-destructive" title={s.error}>
264 ⚠ {s.error}
265 </span>
266 ) : (
267 <span className="font-mono">{s.value == null ? "∅" : String(s.value)}</span>
268 )}
269 {i > 4 && null}
270 </li>
271 ))}
272 </ul>
273 </div>
274 )}
275
276 <p className="mt-auto pt-3 text-[11px] text-muted-foreground">
277 Formulas are evaluated by the server's safe parser — arbitrary code is never executed. Property references use the
278 property name, e.g. <code className="rounded bg-muted px-1">status</code>, or the syntax shown in the function list.
279 </p>
280 </div>
281 </div>
282 </div>
283 </div>
284 );
285}
286