"use client"; /** * DatabaseToolbar — filter / sort / group / search-in-view controls for a * database view. * * State model (frontend-architecture §4.1: "anything shareable via link * lives in the URL, never in a store"): * saved view (filter/sort/group on the DatabaseView row) * + ad-hoc overrides (store, per dbId:viewId) * → composed tree * → useDatabaseRecords (server-side engine) * The composed state is mirrored into the URL (?filter=&sort=&group=&q=) * via router.replace so the current lens is shareable; on mount the URL * wins over the store. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { CalendarRange, Filter, Group, Loader2, Search, SlidersHorizontal, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { useDatabaseStore } from "@/stores/database-store"; import { andFilters, DatabaseProperty, DatabaseView, FilterTree, GroupSpec, searchFilter, SortSpec } from "@/hooks/use-database"; import { FilterBuilder } from "@/components/database/filter-builder"; import { SortBuilder } from "@/components/database/sort-builder"; import { GroupBuilder } from "@/components/database/group-builder"; type Pop = "filter" | "sort" | "group" | null; export interface DatabaseToolbarProps { databaseId: string; view: DatabaseView; properties: DatabaseProperty[]; className?: string; } function encode(v: unknown): string | null { if (v == null) return null; if (Array.isArray(v) && v.length === 0) return null; return JSON.stringify(v); } function decode(s: string | null): T | null { if (!s) return null; try { return JSON.parse(s) as T; } catch { return null; } } export function DatabaseToolbar({ databaseId, view, properties, className }: DatabaseToolbarProps) { const router = useRouter(); const searchParams = useSearchParams(); const [pop, setPop] = useState(null); const rootRef = useRef(null); const overrides = useDatabaseStore((s) => s.overrides[`${databaseId}:${view.id}`]); const setOverrides = useDatabaseStore((s) => s.setOverrides); const clearOverrides = useDatabaseStore((s) => s.clearOverrides); // ---- URL → store hydration (URL wins on mount) ---------------------------- const hydrated = useRef(false); useEffect(() => { if (hydrated.current) return; hydrated.current = true; const patch: Record = {}; const f = decode(searchParams.get("filter")); const so = decode(searchParams.get("sort")); const g = decode(searchParams.get("group")); const q = searchParams.get("q"); if (f) patch.filter = f; if (so) patch.sort = so; if (g) patch.group = g; if (q != null) patch.searchText = q; if (Object.keys(patch).length > 0) setOverrides(databaseId, view.id, patch); // eslint-disable-next-line react-hooks/exhaustive-deps }, [databaseId, view.id]); // ---- composed effective state -------------------------------------------- const effectiveFilter = useMemo( () => andFilters(view.filter, overrides?.filter ?? null, searchFilter(properties, overrides?.searchText ?? "")), [view.filter, overrides?.filter, overrides?.searchText, properties] ); const effectiveSort = useMemo( () => overrides?.sort ?? view.sort ?? null, [overrides?.sort, view.sort] ); const effectiveGroup = useMemo( () => overrides?.group ?? view.group ?? null, [overrides?.group, view.group] ); // ---- mirror effective state into the URL (shareable) ---------------------- useEffect(() => { const params = new URLSearchParams(searchParams.toString()); const setOrDelete = (key: string, value: string | null) => { if (value == null) params.delete(key); else params.set(key, value); }; setOrDelete("filter", overrides?.filter ? encode(overrides.filter) : null); setOrDelete("sort", overrides?.sort ? encode(overrides.sort) : null); setOrDelete("group", overrides?.group ? encode(overrides.group) : null); setOrDelete("q", overrides?.searchText || null); const qs = params.toString(); const next = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; if (window.location.pathname + (qs ? `?${qs}` : "") !== window.location.pathname + (searchParams.toString() ? `?${searchParams.toString()}` : "")) { router.replace(next, { scroll: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [overrides?.filter, overrides?.sort, overrides?.group, overrides?.searchText]); // Close popovers on outside click. useEffect(() => { if (!pop) return; const onDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setPop(null); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [pop]); // ---- live match count while editing (server-side, pageSize: 1) ------------ const [preview, setPreview] = useState<{ filter: FilterTree | null; count: number | null } | null>(null); const editingFilter = pop === "filter" && preview?.filter === effectiveFilter ? preview.count : null; const countQuery = useQuery({ queryKey: ["db-count", databaseId, view.id, JSON.stringify(effectiveFilter ?? null)], enabled: false, // triggered manually below queryFn: async () => { const b = await apiFetch(`/databases/${databaseId}/query`, { method: "POST", body: JSON.stringify({ filter: effectiveFilter ?? undefined, pageSize: 1 }) }); const d = (b as { data?: { totalCount?: number } }).data ?? b; return (d as { totalCount?: number }).totalCount ?? null; } }); const refreshCount = useCallback(() => { setPreview({ filter: effectiveFilter, count: null }); void countQuery.refetch(); }, [effectiveFilter, countQuery]); useEffect(() => { if (pop === "filter" && countQuery.data != null) { setPreview((p) => (p ? { ...p, count: countQuery.data } : p)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [countQuery.data, pop]); // ---- chip helpers ----------------------------------------------------------- const conditionCount = useMemo(() => { let n = 0; const walk = (node: FilterTree | null | undefined) => { if (!node) return; if (typeof (node as { propertyId?: string }).propertyId === "string") n += 1; else for (const c of (node as { conditions: FilterTree[] }).conditions) walk(c); }; walk(effectiveFilter); return n; }, [effectiveFilter]); const chipCls = "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs hover:bg-muted"; return (
{/* Active filter chips */} {view.filter && ( {conditionCount} saved )} {overrides?.filter && ( )} {overrides?.searchText && ( )} {/* Search in view */}
setOverrides(databaseId, view.id, { searchText: e.target.value || undefined })} aria-label="Search within this view" /> {overrides?.searchText && ( )}
{/* Filter */}
{pop === "filter" && (
Filters {countQuery.isFetching && } {editingFilter != null ? `${editingFilter} records match` : countQuery.data != null ? `${countQuery.data} records match` : ""}
{ setOverrides(databaseId, view.id, { filter: tree ?? undefined }); refreshCount(); }} properties={properties} />
)}
{/* Sort */}
{pop === "sort" && (

Sorts

setOverrides(databaseId, view.id, { sort: sort ?? undefined })} properties={properties} />
)}
{/* Group */}
{pop === "group" && (

Group by

setOverrides(databaseId, view.id, { group: group ?? undefined })} properties={properties} viewType={view.type} />
)}
{/* Reset ad-hoc overrides */} {(overrides?.filter || overrides?.sort || overrides?.group || overrides?.searchText) && ( )}
); }