"use client"; /** * FormulaEditor — dialog for writing/editing a formula property expression. * * Expressions are validated server-side (safe AST parser — never eval, * PRODUCT-SPEC §9.6 / AGENTS rule 18): debounced POST * /databases/:id/formulas/validate with sampleRecordIds, showing the * returned returnType + sample values or the error with its position. * * A function library cheat-sheet (left column) lets users insert functions. * Saving PATCHes the property's config.expression. * * Contract (imported by cell-editor.tsx): * */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Check, Loader2, Save, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { DatabaseProperty, FormulaValidationResult, useUpdateProperty, useValidateFormula } from "@/hooks/use-database"; const FUNCTIONS: { name: string; signature: string; desc: string }[] = [ { name: "if", signature: "if(cond, a, b)", desc: "Return a if cond, else b" }, { name: "and", signature: "and(a, b, …)", desc: "Logical AND" }, { name: "or", signature: "or(a, b, …)", desc: "Logical OR" }, { name: "not", signature: "not(a)", desc: "Logical NOT" }, { name: "plus", signature: "plus(a, b, …)", desc: "Numeric sum" }, { name: "minus", signature: "minus(a, b)", desc: "Numeric difference" }, { name: "times", signature: "times(a, b)", desc: "Numeric product" }, { name: "divide", signature: "divide(a, b)", desc: "Numeric quotient (0 → null)" }, { name: "mod", signature: "mod(a, b)", desc: "Remainder" }, { name: "abs", signature: "abs(n)", desc: "Absolute value" }, { name: "round", signature: "round(n, d=0)", desc: "Round to d decimals" }, { name: "min", signature: "min(…)", desc: "Smallest value" }, { name: "max", signature: "max(…)", desc: "Largest value" }, { name: "power", signature: "power(n, e)", desc: "n raised to e" }, { name: "sqrt", signature: "sqrt(n)", desc: "Square root" }, { name: "concat", signature: "concat(a, b, …)", desc: "Concatenate as text" }, { name: "upper", signature: "upper(s)", desc: "Uppercase" }, { name: "lower", signature: "lower(s)", desc: "Lowercase" }, { name: "len", signature: "len(s)", desc: "String length" }, { name: "contains", signature: "contains(s, sub)", desc: "Substring test" }, { name: "trim", signature: "trim(s)", desc: "Trim whitespace" }, { name: "now", signature: "now()", desc: "Current date/time" }, { name: "today", signature: "today()", desc: "Current date" }, { name: "date", signature: "date(iso)", desc: "Parse an ISO date" }, { name: "years", signature: "years(a, b)", desc: "Year difference" }, { name: "months", signature: "months(a, b)", desc: "Month difference" }, { name: "days", signature: "days(a, b)", desc: "Day difference" }, { name: "format", signature: "format(v, f)", desc: "Format per f (currency, percent…)" } ]; export interface FormulaEditorProps { databaseId: string; property: DatabaseProperty; initialExpression: string; /** Record ids to evaluate the expression against (live preview). */ sampleRecordIds: string[]; onClose: () => void; onSaved: () => void; } export function FormulaEditor({ databaseId, property, initialExpression, sampleRecordIds, onClose, onSaved }: FormulaEditorProps) { const [expr, setExpr] = useState(initialExpression); const [validation, setValidation] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const validate = useValidateFormula(databaseId); const updateProperty = useUpdateProperty(databaseId); const inputRef = useRef(null); const timer = useRef | null>(null); // Debounced server-side validation while typing. const scheduleValidate = useCallback( (value: string) => { if (timer.current) clearTimeout(timer.current); if (!value.trim()) { setValidation(null); return; } timer.current = setTimeout(() => { validate.mutate({ expression: value, sampleRecordIds: sampleRecordIds.slice(0, 5) }, { onSuccess: (res) => { setValidation(res); setError(null); }, onError: (e) => { setValidation(null); setError((e as Error).message); } }); }, 400); }, [validate, sampleRecordIds] ); useEffect(() => { inputRef.current?.focus(); return () => { if (timer.current) clearTimeout(timer.current); }; }, []); // Escape closes (but not while a menu is open — no menu here). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onClose]); const insertFunction = (name: string) => { const el = inputRef.current; const token = `${name}(`; if (!el) { setExpr((e) => e + token); return; } const start = el.selectionStart ?? expr.length; const end = el.selectionEnd ?? expr.length; const next = expr.slice(0, start) + token + expr.slice(end); setExpr(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start + token.length - 1, start + token.length - 1); }); scheduleValidate(next); }; const canSave = expr.trim().length > 0 && validation?.ok === true; const functionIndex = useMemo(() => FUNCTIONS.map((f) => f.name.toLowerCase()).sort(), []); return (
{/* header */}
Formula: {property.name}
{/* function library */} {/* editor + preview */}