From 745f122b5b8b4072cc103084566b744fb074440b Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Jul 2026 11:08:43 -0700 Subject: [PATCH] Add unified filter-first analytics table search bar. Debounce substring search, route natural-language and zero-result queries to AI, and show active filters as removable chips. Co-authored-by: Cursor --- .../analytics/tables/table-search-bar.tsx | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx new file mode 100644 index 000000000..04d3ec5b0 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { cn } from "@/lib/utils"; +import { + ArrowElbowDownLeftIcon, + MagnifyingGlassIcon, + SparkleIcon, + SpinnerGapIcon, + XIcon, +} from "@phosphor-icons/react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import type { RowData } from "../shared"; +import type { QueryDataGridToolbarContext } from "./query-data-grid"; +import { looksLikeNaturalLanguageQuery } from "./search-bar-logic"; +import type { AiTableFilterChat } from "./use-ai-table-filter-chat"; + +const SEARCH_DEBOUNCE_MS = 250; + +type TableSearchBarProps = { + /** Extended toolbar context from QueryDataGrid (grid state + fetch state). */ + ctx: QueryDataGridToolbarContext, + /** + * The query currently driving the grid. When it changes, the grid resets + * its own state (incl. `quickSearch`), so the bar drops any typed-but-now + * -stale search text to stay in sync. + */ + queryKey: string, + /** Chat thread powering the AI filter fallback. */ + chat: AiTableFilterChat, + /** The validated AI row filter currently applied to the grid, or null. */ + activeFilterQuery: string | null, + /** True when the AI committed a query that failed shape validation and was + * therefore NOT applied. */ + filterRejected: boolean, + /** Sends a natural-language filter request to the AI. */ + onAiSubmit: (text: string) => void, + /** Clears the AI row filter (back to the plain table). */ + onClearFilter: () => void, +}; + +/** Compact removable chip showing an active AI query/filter. */ +function ActiveQueryChip({ + label, + query, + onClear, + clearLabel, +}: { + label: string, + query: string, + onClear: () => void, + clearLabel: string, +}) { + return ( + + {query} + + } + > + + + {label} + + + + ); +} + +/** + * Filter-first search bar for the analytics tables page. Drops into the + * DataGridToolbar in place of the built-in quick-search input. + * + * - Typing applies a debounced substring (ILIKE) filter over the table via + * `state.quickSearch` — columns, sort, and pagination stay untouched. + * - Input that a substring can't express (natural language, comparisons, or + * searches that came back empty) is routed to the AI on Enter; the AI + * responds with a row filter over the SAME table, shown as a removable + * chip. ⌘/Ctrl+Enter always asks the AI. + */ +export function TableSearchBar({ + ctx, + queryKey, + chat, + activeFilterQuery, + filterRejected, + onAiSubmit, + onClearFilter, +}: TableSearchBarProps) { + const [input, setInput] = useState(""); + // The notice (AI text reply / rejection / error) the user has dismissed — + // compared by content so a NEW notice shows again. + const [dismissedNotice, setDismissedNotice] = useState(null); + + const { onChange } = ctx; + const debounceRef = useRef | null>(null); + + // Grid query changed (AI filter/builder applied or cleared) → the grid + // just reset its quickSearch, so typed text left in the input would look + // applied when it isn't. Drop it and cancel any pending debounce. + const prevQueryKeyRef = useRef(queryKey); + useEffect(() => { + if (prevQueryKeyRef.current === queryKey) return; + prevQueryKeyRef.current = queryKey; + if (debounceRef.current != null) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + setInput(""); + }, [queryKey]); + + const applyQuickSearch = useCallback( + (value: string) => { + onChange((s) => + s.quickSearch === value + ? s + : { + ...s, + quickSearch: value, + // Reset to the first page — the filtered result set may not + // reach the page the user had scrolled to. + pagination: { ...s.pagination, pageIndex: 0 }, + }, + ); + }, + [onChange], + ); + + // Each keystroke re-runs the ClickHouse query, so writes to + // `state.quickSearch` are debounced. Clearing skips the debounce — showing + // the unfiltered table again should feel instant. + const scheduleQuickSearch = useCallback( + (value: string) => { + if (debounceRef.current != null) clearTimeout(debounceRef.current); + const trimmed = value.trim(); + if (trimmed.length === 0) { + debounceRef.current = null; + applyQuickSearch(""); + return; + } + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + applyQuickSearch(trimmed); + }, SEARCH_DEBOUNCE_MS); + }, + [applyQuickSearch], + ); + + useEffect(() => { + return () => { + if (debounceRef.current != null) clearTimeout(debounceRef.current); + }; + }, []); + + const flushQuickSearch = useCallback(() => { + if (debounceRef.current != null) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + applyQuickSearch(input.trim()); + }, [applyQuickSearch, input]); + + const trimmedInput = input.trim(); + const isNaturalLanguage = useMemo( + () => looksLikeNaturalLanguageQuery(trimmedInput), + [trimmedInput], + ); + // The plain filter came back empty for exactly what's typed — a substring + // match can't help, so offer the AI even for plain-looking terms. + const zeroResults = + trimmedInput.length > 0 + && ctx.state.quickSearch === trimmedInput + && !ctx.isLoading + && !ctx.isRefetching + && ctx.rowCount === 0; + const wantsAi = trimmedInput.length > 0 && (isNaturalLanguage || zeroResults); + + const submitAi = useCallback(() => { + const text = input.trim(); + if (text.length === 0 || chat.isResponding) return; + if (debounceRef.current != null) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + // The text is a question for the AI, not a substring to match. + applyQuickSearch(""); + setInput(""); + setDismissedNotice(null); + onAiSubmit(text); + }, [input, chat.isResponding, applyQuickSearch, onAiSubmit]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key !== "Enter" || e.shiftKey) return; + e.preventDefault(); + if (e.metaKey || e.ctrlKey) { + submitAi(); + return; + } + if (wantsAi) { + submitAi(); + } else { + flushQuickSearch(); + } + }, + [submitAi, flushQuickSearch, wantsAi], + ); + + const handleInputChange = useCallback( + (value: string) => { + setInput(value); + scheduleQuickSearch(value); + }, + [scheduleQuickSearch], + ); + + const clearInput = useCallback(() => { + setInput(""); + if (debounceRef.current != null) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + applyQuickSearch(""); + }, [applyQuickSearch]); + + // Chip label: the natural-language request that produced the active filter. + const activeFilterLabel = useMemo(() => { + if (activeFilterQuery == null) return null; + for (let i = chat.messages.length - 1; i >= 0; i--) { + const msg = chat.messages[i]!; + if (msg.role !== "user") continue; + const part = msg.content.find((p) => p.type === "text"); + if (part?.type === "text" && part.text.trim().length > 0) { + return part.text.trim(); + } + } + return "AI filter"; + }, [activeFilterQuery, chat.messages]); + + const rejectionMessage = filterRejected + ? "The AI answered with a query that would change this table's columns, so it wasn't applied. Try rephrasing your request." + : null; + const notice = chat.error?.message ?? chat.assistantNote ?? rejectionMessage; + const noticeVisible = notice != null && notice !== dismissedNotice; + + const isAiActive = activeFilterQuery != null; + const placeholder = isAiActive + ? "Search within results or refine with AI…" + : "Search rows or ask AI…"; + + return ( + // No flex-1 on the outer wrapper — a flex-1 search would swallow the + // toolbar's leading cluster and push siblings (e.g. the row-count badge) + // to the far right. Width lives on the input shell instead. +
+
+ {chat.isResponding ? ( + + ) : wantsAi || isAiActive ? ( + + ) : ( + + )} + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className={cn( + "min-w-0 flex-1 bg-transparent text-xs outline-none", + "placeholder:text-muted-foreground/40", + )} + aria-label="Search rows or ask AI" + /> + {input.length > 0 && !wantsAi && ( + + )} + {wantsAi && !chat.isResponding && ( + + + + )} +
+ + {activeFilterQuery != null && activeFilterLabel != null && ( + + )} + + {noticeVisible && ( +
+ + {notice} + +
+ )} +
+ ); +}