From f4b7ce4c7df593bf4e0f8d7e91b1835c35f0b27a Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 26 May 2026 14:18:41 +0000 Subject: [PATCH] Generated with Hive: Add useDebounce hook and NodeSearchInput combobox component for node search --- src/components/ui/node-search-input.tsx | 197 +++++++++++++++++++ src/hooks/use-debounce.ts | 15 ++ src/lib/__tests__/node-search-input.test.tsx | 189 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/components/ui/node-search-input.tsx create mode 100644 src/hooks/use-debounce.ts create mode 100644 src/lib/__tests__/node-search-input.test.tsx diff --git a/src/components/ui/node-search-input.tsx b/src/components/ui/node-search-input.tsx new file mode 100644 index 0000000..340e795 --- /dev/null +++ b/src/components/ui/node-search-input.tsx @@ -0,0 +1,197 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { X, Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { displayNodeType } from "@/lib/utils" +import { resolveNodeTitle } from "@/lib/node-display" +import { searchNodes, type GraphNode } from "@/lib/graph-api" +import { useDebounce } from "@/hooks/use-debounce" + +interface NodeSearchInputProps { + value: GraphNode | null + onChange: (node: GraphNode | null) => void + placeholder?: string + disabled?: boolean +} + +export function NodeSearchInput({ + value, + onChange, + placeholder = "Search nodes…", + disabled = false, +}: NodeSearchInputProps) { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [fetched, setFetched] = useState(false) + + const containerRef = useRef(null) + const abortRef = useRef(null) + + const debouncedQuery = useDebounce(query, 300) + + // Outside-click closes dropdown + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + if (open) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [open]) + + // Debounced search + useEffect(() => { + if (value !== null) return // don't search in selected state + + if (debouncedQuery.trim() === "") { + setResults([]) + setFetched(false) + setOpen(false) + return + } + + // Cancel previous in-flight request + if (abortRef.current) { + abortRef.current.abort() + } + const controller = new AbortController() + abortRef.current = controller + + setLoading(true) + setFetched(false) + + searchNodes(debouncedQuery, { limit: 10 }, controller.signal) + .then((res) => { + if (!controller.signal.aborted) { + setResults(res.nodes) + setFetched(true) + setOpen(true) + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setResults([]) + setFetched(true) + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false) + } + }) + + return () => { + controller.abort() + } + }, [debouncedQuery, value]) + + function handleClear() { + onChange(null) + setQuery("") + setResults([]) + setFetched(false) + setOpen(false) + } + + function handleSelect(node: GraphNode) { + onChange(node) + setQuery("") + setResults([]) + setFetched(false) + setOpen(false) + } + + // Selected state + if (value !== null) { + const title = resolveNodeTitle(value, []) + const typeLabel = displayNodeType(value.node_type) + + return ( +
+ {title} + + {typeLabel} + + {!disabled && ( + + )} +
+ ) + } + + // Search state + const showDropdown = open && (loading || fetched) + + return ( +
+
+ setQuery(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={cn( + "w-full rounded-md border border-border/50 bg-muted/50 h-10 px-3 text-sm text-foreground", + "placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none", + "disabled:cursor-not-allowed disabled:opacity-50" + )} + /> + {loading && ( + + )} +
+ + {showDropdown && ( +
+ {loading && results.length === 0 ? null : fetched && results.length === 0 ? ( +
No nodes found
+ ) : ( + results.map((node) => { + const title = resolveNodeTitle(node, []) + const typeLabel = displayNodeType(node.node_type) + const truncatedId = + node.ref_id.length > 12 ? node.ref_id.slice(0, 12) + "…" : node.ref_id + + return ( + + ) + }) + )} +
+ )} +
+ ) +} diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts new file mode 100644 index 0000000..fe21544 --- /dev/null +++ b/src/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +import { useState, useEffect } from "react" + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => clearTimeout(timer) + }, [value, delay]) + + return debouncedValue +} diff --git a/src/lib/__tests__/node-search-input.test.tsx b/src/lib/__tests__/node-search-input.test.tsx new file mode 100644 index 0000000..1b4197e --- /dev/null +++ b/src/lib/__tests__/node-search-input.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, fireEvent } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import React from "react" + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { mockSearchNodes } = vi.hoisted(() => ({ + mockSearchNodes: vi.fn(), +})) + +vi.mock("@/lib/graph-api", () => ({ + searchNodes: (...args: unknown[]) => mockSearchNodes(...args), +})) + +vi.mock("@/lib/node-display", () => ({ + resolveNodeTitle: (node: { ref_id: string; properties?: Record }) => { + return (node.properties?.name as string) ?? node.ref_id + }, +})) + +// --------------------------------------------------------------------------- +// Import after mocks +// --------------------------------------------------------------------------- +import { NodeSearchInput } from "@/components/ui/node-search-input" +import type { GraphNode } from "@/lib/graph-api" + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const NODE_A: GraphNode = { + ref_id: "node-abc-123", + node_type: "Person", + properties: { name: "Alice" }, +} + +const NODE_B: GraphNode = { + ref_id: "node-xyz-456", + node_type: "Topic", + properties: { name: "Blockchain" }, +} + +const NODE_LONG_ID: GraphNode = { + ref_id: "averylongrefidthatexceeds12chars", + node_type: "Content", + properties: { name: "Long ID Node" }, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("NodeSearchInput", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSearchNodes.mockResolvedValue({ nodes: [NODE_A, NODE_B] }) + }) + + it("renders search input when value is null", () => { + render() + expect(screen.getByPlaceholderText("Find node…")).toBeInTheDocument() + }) + + it("renders selected state with title and type badge when value is a node", () => { + render() + expect(screen.getByText("Alice")).toBeInTheDocument() + expect(screen.getByText("Person")).toBeInTheDocument() + // No text input in selected state + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + }) + + it("does NOT call searchNodes when query is empty", async () => { + render() + const input = screen.getByRole("textbox") + await userEvent.click(input) + // No typing — just wait a bit + await new Promise((r) => setTimeout(r, 400)) + expect(mockSearchNodes).not.toHaveBeenCalled() + }) + + it("calls searchNodes after debounce when user types a query", async () => { + render() + const input = screen.getByRole("textbox") + await userEvent.type(input, "alice") + await waitFor(() => expect(mockSearchNodes).toHaveBeenCalledWith( + "alice", + { limit: 10 }, + expect.anything() + ), { timeout: 1000 }) + }) + + it("renders results dropdown with title, type badge, and truncated ref_id", async () => { + render() + const input = screen.getByRole("textbox") + await userEvent.type(input, "alice") + + await waitFor(() => { + expect(screen.getByText("Alice")).toBeInTheDocument() + expect(screen.getByText("Blockchain")).toBeInTheDocument() + }, { timeout: 1000 }) + + // Type badges + const personBadges = screen.getAllByText("Person") + expect(personBadges.length).toBeGreaterThan(0) + expect(screen.getByText("Topic")).toBeInTheDocument() + + // Truncated ref_ids + expect(screen.getByText("node-abc-123")).toBeInTheDocument() // 12 chars exactly — no truncation + expect(screen.getByText("node-xyz-456")).toBeInTheDocument() + }) + + it("truncates ref_id longer than 12 chars with ellipsis", async () => { + mockSearchNodes.mockResolvedValue({ nodes: [NODE_LONG_ID] }) + render() + const input = screen.getByRole("textbox") + await userEvent.type(input, "long") + + await waitFor(() => { + expect(screen.getByText("averylongref…")).toBeInTheDocument() + }, { timeout: 1000 }) + }) + + it("calls onChange with correct node and closes dropdown when result is clicked", async () => { + const onChange = vi.fn() + render() + const input = screen.getByRole("textbox") + await userEvent.type(input, "alice") + + await waitFor(() => screen.getByText("Alice"), { timeout: 1000 }) + await userEvent.click(screen.getByText("Alice")) + + expect(onChange).toHaveBeenCalledWith(NODE_A) + // Dropdown should be gone + expect(screen.queryByText("Blockchain")).not.toBeInTheDocument() + }) + + it("shows 'No nodes found' empty state when API returns empty array", async () => { + mockSearchNodes.mockResolvedValue({ nodes: [] }) + render() + const input = screen.getByRole("textbox") + await userEvent.type(input, "xyz") + + await waitFor(() => { + expect(screen.getByText("No nodes found")).toBeInTheDocument() + }, { timeout: 1000 }) + }) + + it("X clear button resets to search state and calls onChange(null)", async () => { + const onChange = vi.fn() + render() + + // Selected state visible + expect(screen.getByText("Alice")).toBeInTheDocument() + + const clearBtn = screen.getByRole("button", { name: /clear selection/i }) + await userEvent.click(clearBtn) + + expect(onChange).toHaveBeenCalledWith(null) + }) + + it("disabled prop disables the text input", () => { + render() + const input = screen.getByRole("textbox") as HTMLInputElement + expect(input.disabled).toBe(true) + }) + + it("outside click closes the open dropdown", async () => { + render( +
+ +
outside
+
+ ) + const input = screen.getByRole("textbox") + await userEvent.type(input, "alice") + + await waitFor(() => screen.getByText("Alice"), { timeout: 1000 }) + + // Click outside + fireEvent.mouseDown(screen.getByTestId("outside")) + + await waitFor(() => { + expect(screen.queryByText("Alice")).not.toBeInTheDocument() + }) + }) +})