"use client"; import { type AnyFieldDef, compile, type EditorSpec, encode, type Fields, fieldLabel, formatInput, formatList, type Issue, type PathIssue, parseInput, parseList, type Query, type QueryNode, type QueryOf, validateQuery, } from "@qnot/core"; import { sqlFormat } from "@qnot/core/formats/sql"; import { textFormat } from "@qnot/core/formats/text"; import { QueryProvider, useDragHandle, useDropTarget, useGroup, useGroupDropZone, useHistory, useNode, useQuery, useQueryStore, useRule, useStore, } from "@qnot/react"; import { CalendarIcon, CheckIcon, ChevronsUpDownIcon, CopyIcon, CopyPlusIcon, EllipsisIcon, FolderPlusIcon, GripVerticalIcon, HashIcon, ListIcon, PlusIcon, Redo2Icon, TagsIcon, ToggleLeftIcon, Trash2Icon, TypeIcon, Undo2Icon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Menu, MenuCheckboxItem, MenuItem, MenuPopup, MenuSeparator, MenuTrigger, } from "@/components/ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export interface RuleBuilderProps { fields: Fields; defaultQuery?: Query; rows?: readonly Record[]; table?: string; noun?: { one: string; many: string }; maxDepth?: number; onChange?: (query: Query) => void; className?: string; } type OutputTab = "sql" | "json" | "text"; const postgres = sqlFormat({ dialect: "postgres", params: "numbered" }); const english = textFormat(); const easeOut = [0.23, 1, 0.32, 1] as const; const glide = { type: "spring", duration: 0.3, bounce: 0 } as const; const emptyQuery: Query = { kind: "group", combinator: "and", children: [] }; const kindIcons: Record = { text: TypeIcon, number: HashIcon, enum: ListIcon, multiEnum: TagsIcon, boolean: ToggleLeftIcon, date: CalendarIcon, }; export function RuleBuilder({ fields, defaultQuery = emptyQuery, rows, table = "accounts", noun = { one: "account", many: "accounts" }, maxDepth = 2, onChange, className, }: RuleBuilderProps) { const store = useQueryStore({ fields, initial: defaultQuery as QueryOf, settings: useMemo(() => ({ maxDepth }), [maxDepth]), onChange: onChange as ((q: QueryOf) => void) | undefined, }); const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.closest("input, textarea")) return; if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "z") return; e.preventDefault(); if (e.shiftKey) store.redo(); else store.undo(); }; return (
); } function Header({ rows, noun }: { rows?: readonly Record[]; noun: { one: string; many: string } }) { const store = useStore(); const query = useQuery(); const { canUndo, canRedo, undo, redo } = useHistory(); const count = useMemo(() => { if (!rows) return null; const issues = validateQuery(store.fields, query); if (issues.some((i) => i.severity === "error" && i.code !== "required")) return null; const test = compile(store.fields, query); return rows.filter((row) => test(row as never)).length; }, [rows, store, query]); return (

Filter {noun.many}

{rows ? count === null ? "Fix the highlighted rule to see matches" : `${count} of ${rows.length} ${rows.length === 1 ? noun.one : noun.many} match` : `${store.ruleCount} ${store.ruleCount === 1 ? "rule" : "rules"}`}

); } function IconAction({ label, shortcut, disabled, onClick, children, }: { label: string; shortcut?: string; disabled?: boolean; onClick: () => void; children: ReactNode; }) { return ( } > {children} {label} {shortcut && {shortcut}} ); } const GroupView = memo(function GroupView({ id, depth }: { id: string; depth: number }) { const reduce = useReducedMotion(); const { group, isRoot, can, setCombinator, setNot, addRule, addGroup, clone, remove } = useGroup(id); const { isOver, zoneProps } = useGroupDropZone(id); const { isDragging, handleProps } = useDragHandle(id); const { position, dropProps } = useDropTarget(id); const other = group.combinator === "and" ? "or" : "and"; const summary = group.not ? "None of these" : group.combinator === "and" ? "All of these" : "Any of these"; const list = (
{group.children.map((child, index) => ( setCombinator(other) : undefined} value={group.combinator} />
))}
{group.children.length === 0 && (
{isRoot ? "No rules yet, so every row matches." : "Empty group. Add a rule or drop one here."}
)}
); if (isRoot) { return (
{list}
0 && "ps-[2.875rem] @lg:ps-[3.375rem]")}>
); } return (
{summary} addRule()} size="icon-sm" variant="ghost" /> } > Add rule }> addGroup()}> Add nested group clone()}> Duplicate group setNot(v)}> Match none of these Delete group
{list}
); }); function NodeView({ id, depth }: { id: string; depth: number }) { const node = useNode(id); if (!node) return null; return node.kind === "group" ? : ; } function Connector({ id, index, value, onToggle, }: { id: string; index: number; value: "and" | "or"; onToggle?: () => void; }) { const isGroup = useNode(id)?.kind === "group"; const base = cn("flex h-8 w-10 shrink-0 items-center text-xs sm:h-7 @lg:w-12", isGroup && "mt-[0.4375rem]"); if (index === 0) return Where; if (index === 1 && onToggle) { return ( ); } return {value}; } function DropLine({ position }: { position: "before" | "after" | "inside" | null }) { if (position !== "before" && position !== "after") return null; return ( ); } function DragHandle({ id, label, handleProps, }: { id: string; label: string; handleProps: ReturnType["handleProps"]; }) { const store = useStore(); const nudge = (delta: -1 | 1) => { const node = store.get(id); if (!node?.parent) return; const siblings = store.getGroup(node.parent).children; const index = siblings.indexOf(id); const target = delta < 0 ? index - 1 : index + 2; if (target < 0 || target > siblings.length) return; store.move(id, node.parent, target); }; return ( ); } const RuleRow = memo(function RuleRow({ id }: { id: string }) { const reduce = useReducedMotion(); const { rule, def, fields, editor, operators, fieldOptions, issues, can, setField, setOperator, setValue, clone, remove } = useRule(id); const { isDragging, handleProps } = useDragHandle(id); const { position, dropProps } = useDropTarget(id); const [unreadable, setUnreadable] = useState(false); const messages = unreadable ? ["Enter a number"] : issues.filter((i) => i.severity === "error" && i.code !== "required").map((i) => i.message); const field = typeof rule.field === "string" ? rule.field : ""; const label = def ? fieldLabel(field, def) : field; const FieldIcon = kindIcons[def?.kind ?? "text"] ?? TypeIcon; const fieldItems = fieldOptions.map((o) => ({ value: o.value, label: o.label })); return (
} > clone()}> Duplicate Delete rule
{messages.length > 0 && ( {messages.join(" · ")} )}
); }); function ValueEditor({ editor, def, value, issues, label, disabled, onChange, onUnreadable, }: { editor: EditorSpec; def: AnyFieldDef | undefined; value: unknown; issues: readonly Issue[]; label: string; disabled?: boolean; onChange: (value: unknown) => void; onUnreadable: (unreadable: boolean) => void; }) { const invalidAt = (index?: number) => issues.some((i) => i.severity === "error" && i.code !== "required" && (index === undefined || i.index === undefined || i.index === index)); if (editor.arity === "none") return null; if (editor.arity === "two") { const pair = Array.isArray(value) ? value : [null, null]; return (
onChange([v, pair[1] ?? null])} onUnreadable={onUnreadable} value={pair[0]} /> and onChange([pair[0] ?? null, v])} onUnreadable={onUnreadable} value={pair[1]} />
); } if (editor.type === "multiselect") { const options = editor.options ?? []; const selected = Array.isArray(value) ? value.map(String) : []; return ( ); } if (editor.type === "list") { return ( parseList(def, raw)} placeholder="Comma, separated" value={value} /> ); } return ( ); } function Scalar({ editor, def, value, invalid, label, disabled, onChange, onUnreadable, }: { editor: EditorSpec; def: AnyFieldDef | undefined; value: unknown; invalid: boolean; label: string; disabled?: boolean; onChange: (value: unknown) => void; onUnreadable: (unreadable: boolean) => void; }) { if (editor.type === "select" || editor.type === "boolean") { const items = editor.type === "boolean" ? [ { value: "true", label: "Yes" }, { value: "false", label: "No" }, ] : (editor.options ?? []).map((o) => ({ value: String(o.value), label: o.label })); return ( ); } if (editor.type === "date") { return ( onChange(parseInput(def, e.target.value))} size="sm" type="date" value={formatInput(value)} /> ); } return ( parseInput(def, raw)} placeholder={editor.type === "number" ? "0" : "Value"} value={value} /> ); } function DraftInput({ value, parse, format, invalid, label, placeholder, inputMode, disabled, onChange, onUnreadable, }: { value: unknown; parse: (raw: string) => unknown; format: (value: unknown) => string; invalid: boolean; label: string; placeholder?: string; inputMode?: "decimal"; disabled?: boolean; onChange: (value: unknown) => void; onUnreadable: (unreadable: boolean) => void; }) { const [draft, setDraft] = useState(null); const unreadable = draft !== null && draft.trim() !== "" && parse(draft) === null; useEffect(() => { onUnreadable(unreadable); return () => onUnreadable(false); }, [unreadable, onUnreadable]); return ( setDraft(null)} onChange={(e) => { setDraft(e.target.value); onChange(parse(e.target.value)); }} onFocus={() => setDraft(format(value))} placeholder={placeholder} size="sm" value={draft ?? format(value)} /> ); } function OutputPanel({ fields, table }: { fields: Fields; table: string }) { const query = useQuery(); const [tab, setTab] = useState("sql"); const issues = useMemo(() => validateQuery(fields, query), [fields, query]); const incomplete = issues.filter((i) => i.code === "required").length; const blocking = issues.filter((i) => i.severity === "error" && i.code !== "required"); const output = useMemo(() => { if (blocking.length) return null; try { const { sql, params } = encode(postgres, query, fields); return { sql: sql === "1 = 1" ? `SELECT * FROM ${table}` : `SELECT * FROM ${table}\nWHERE ${prettyWhere(sql)}`, params, json: JSON.stringify(query, null, 2), text: encode(english, query, fields) || "Every row", }; } catch { return null; } }, [blocking.length, query, fields, table]); const copyText = output ? (tab === "sql" ? output.sql : tab === "json" ? output.json : output.text) : ""; return (
setTab(v as OutputTab)} value={tab}> SQL JSON Plain English
{output ? ( tab === "sql" ? (
{highlightSql(output.sql)}
{incomplete > 0 && (

Skipping {incomplete} incomplete {incomplete === 1 ? "rule" : "rules"} until {incomplete === 1 ? "it has" : "they have"} a value.

)} {output.params.length > 0 && (
{output.params.map((p, i) => ( ${i + 1} {JSON.stringify(p)} ))}
)}
) : tab === "json" ? (
{highlightJson(output.json)}
) : (

{output.text}

) ) : ( )}
); } function BlockingIssues({ issues, query, fields }: { issues: PathIssue[]; query: Query; fields: Fields }) { return (

Fix {issues.length} {issues.length === 1 ? "rule" : "rules"} to export this filter

    {issues.map((issue) => (
  • {nodeLabel(fields, query, issue.path)} {issue.message}
  • ))}
); } function nodeLabel(fields: Fields, query: Query, path: readonly number[]) { let node: QueryNode | undefined = query; for (const index of path) node = node && node.kind === "group" ? node.children[index] : undefined; if (!node) return "Rule"; if (node.kind === "group") return "Group"; const name = typeof node.field === "string" ? node.field : "Function"; return fields[name] ? fieldLabel(name, fields[name]) : name; } function CopyButton({ text, disabled }: { text: string; disabled?: boolean }) { const [copied, setCopied] = useState(false); const timer = useRef | null>(null); useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); return ( ); } function prettyWhere(sql: string) { let depth = 0; let quoted = false; let out = ""; for (let i = 0; i < sql.length; i++) { const ch = sql[i]; if (ch === "'") quoted = !quoted; if (!quoted && ch === "(") depth++; if (!quoted && ch === ")") depth--; if (!quoted && depth === 0) { const rest = sql.slice(i); const joiner = rest.startsWith(" AND ") ? "AND" : rest.startsWith(" OR ") ? "OR" : null; if (joiner) { out += `\n ${joiner} `; i += joiner.length + 1; continue; } } out += ch; } return out; } const sqlKeywords = /^(SELECT|FROM|WHERE|AND|OR|NOT|IN|LIKE|ILIKE|ESCAPE|BETWEEN|IS|NULL|TRUE|FALSE)$/; function highlightSql(sql: string) { return sql.split(/(\$\d+|'(?:[^']|'')*'|\b[A-Z]+\b)/g).map((part, i) => { if (/^\$\d+$/.test(part)) return ( {part} ); if (part.startsWith("'")) return ( {part} ); if (sqlKeywords.test(part)) return ( {part} ); return part; }); } function highlightJson(json: string) { return json.split(/("(?:[^"\\]|\\.)*"(?:\s*:)?|\b-?\d+(?:\.\d+)?\b|\btrue\b|\bfalse\b|\bnull\b)/g).map((part, i) => { if (/^".*:$/.test(part)) return ( {part} ); if (part.startsWith('"')) return ( {part} ); if (/^(-?\d|true|false|null)/.test(part)) return ( {part} ); return part; }); }