"use client"; // Top-level composite-search UI. Renders the root group of a QueryNode tree // and wires up immutable-mutation callbacks (replaceNode / removeNode / // insertChild / wrapInGroup from `searchQuery.ts`). The parent owns the // committed root; this component is fully controlled. // // When the tree is "trivially simple" — root AND group with exactly one // non-negated leaf — the builder renders in compact mode (one input row + // a small "Add layer" link), preserving the look of the original search bar. import { insertChild, isCompactRoot, newGroup, newLeaf, normalizeRoot, removeNode, replaceNode, unwrapGroup, wrapInGroup, type GroupNode, type LeafNode, type QueryNode, } from "../lib/searchQuery"; import type { ReactNode } from "react"; import type { GroupState, LeafState } from "../lib/searchEval"; import QueryGroupView from "./QueryGroupView"; import QueryLeafView from "./QueryLeafView"; import { Button } from "./ui/button"; type Props = { root: GroupNode; onChange: (next: GroupNode) => void; leafStates: ReadonlyMap; groupStates: ReadonlyMap; /** Slotted beside the compact leaf's input — the search bar's submit button. */ inputTrailing?: ReactNode; /** The search bar renders "+ Add layer" / "+ Add group" in its own chip row * (see `compactLayerActions`), so it turns the builder's own pair off rather * than shipping two of each. */ showCompactActions?: boolean; }; // The two compact-mode tree edits, as callables. Exported because the search // bar renders them as chips OUTSIDE this component and they must stay one // implementation — the same normalize-on-write contract as `emit` below. export function compactLayerActions( root: GroupNode, onChange: (next: GroupNode) => void, ) { const emit = (next: GroupNode) => onChange(normalizeRoot(next)); return { addLayer: () => emit( insertChild( root, root.id, newLeaf({ scope: "transcripts", contributeHits: true }), ), ), addGroup: () => emit( insertChild( root, root.id, newGroup({ op: "OR", children: [newLeaf({ scope: "transcripts" })] }), ), ), }; } export default function QueryBuilder({ root, onChange, leafStates, groupStates, inputTrailing, showCompactActions = true, }: Props) { // All mutations flow through `emit` so the result is normalized: when a // change collapses the tree into compact mode, options the compact UI hides // (e.g. "Show hits in results") revert to their leaf defaults rather than // staying stuck with no control to toggle them. See `normalizeRoot`. const emit = (next: GroupNode) => onChange(normalizeRoot(next)); const onChangeNode = (id: string, next: QueryNode) => { if (id === root.id) { if (next.kind === "group") emit(next); return; } emit(replaceNode(root, id, next)); }; const onRemoveNode = (id: string) => emit(removeNode(root, id)); const onAddChild = (groupId: string, child: QueryNode) => emit(insertChild(root, groupId, child)); const onWrapLeaf = (leafId: string) => emit(wrapInGroup(root, leafId, "AND")); const onUnwrapGroup = (groupId: string) => emit(unwrapGroup(root, groupId)); // Compact mode: root AND group with one non-negated leaf, no negation // applied to the root either. Anything else falls back to the full // builder rendering. if (isCompactRoot(root)) { const leaf = root.children[0] as LeafNode; return (
onChangeNode(leaf.id, next)} onDelete={() => { /* not reachable: canDelete=false in compact mode */ }} /> {showCompactActions && (
)}
); } return (
); }