From 8ab631931125cbff977aab46a61133e1d804d64a Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:16:52 +0000 Subject: [PATCH] feat: quick-find search for clients and pets (GRO-46) Closes #119 - Debounced typeahead search on clients/pets pages - Auto-select client from GlobalSearch highlight param - Mobile-friendly with big touch targets - 141-line test suite, all 70 web tests pass Co-Authored-By: Paperclip --- apps/web/src/__tests__/GlobalSearch.test.tsx | 141 +++++++++++++++++++ apps/web/src/pages/Clients.tsx | 26 +++- 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/__tests__/GlobalSearch.test.tsx diff --git a/apps/web/src/__tests__/GlobalSearch.test.tsx b/apps/web/src/__tests__/GlobalSearch.test.tsx new file mode 100644 index 0000000..c6a5bff --- /dev/null +++ b/apps/web/src/__tests__/GlobalSearch.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { GlobalSearch } from "../components/GlobalSearch.js"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +function renderSearch() { + return render( + + + + ); +} + +beforeEach(() => { + mockNavigate.mockReset(); + global.fetch = vi.fn(); +}); + +describe("GlobalSearch", () => { + it("renders the search input with correct aria attributes", () => { + renderSearch(); + const input = screen.getByRole("combobox"); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("aria-label", "Search clients and pets"); + expect(input).toHaveAttribute("placeholder", "Search clients & pets…"); + }); + + it("does not fetch when query is empty or whitespace", async () => { + renderSearch(); + const user = userEvent.setup({ delay: null }); + const input = screen.getByRole("combobox"); + await user.type(input, " "); + // No debounce fires for blank input — verify fetch was never called + await new Promise((r) => setTimeout(r, 350)); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("fetches after debounce and renders client results", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + clients: [{ id: "c1", name: "Alice Johnson", email: "alice@example.com", phone: "555-1234" }], + pets: [], + }), + } as Response); + + renderSearch(); + const user = userEvent.setup({ delay: null }); + await user.type(screen.getByRole("combobox"), "Alice"); + + await waitFor(() => expect(screen.getByText("Alice Johnson")).toBeInTheDocument(), { + timeout: 1500, + }); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=Alice")); + // Section header should appear + expect(screen.getByText("Clients")).toBeInTheDocument(); + }); + + it("fetches after debounce and renders pet results with owner name", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + clients: [], + pets: [ + { id: "p1", name: "Bella", breed: "Golden Retriever", clientId: "c1", ownerName: "Alice Johnson" }, + ], + }), + } as Response); + + renderSearch(); + const user = userEvent.setup({ delay: null }); + await user.type(screen.getByRole("combobox"), "Bella"); + + await waitFor(() => expect(screen.getByText("Bella")).toBeInTheDocument(), { timeout: 1500 }); + expect(screen.getByText("Owner: Alice Johnson")).toBeInTheDocument(); + }); + + it("shows 'No results found' for a query that matches nothing", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ clients: [], pets: [] }), + } as Response); + + renderSearch(); + const user = userEvent.setup({ delay: null }); + await user.type(screen.getByRole("combobox"), "xyzzy"); + + await waitFor(() => expect(screen.getByText("No results found")).toBeInTheDocument(), { + timeout: 1500, + }); + }); + + it("navigates to ?highlight= and clears input when a client result is clicked", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + clients: [{ id: "c1", name: "Alice Johnson", email: null, phone: null }], + pets: [], + }), + } as Response); + + renderSearch(); + const user = userEvent.setup({ delay: null }); + const input = screen.getByRole("combobox"); + await user.type(input, "Alice"); + + await waitFor(() => screen.getByText("Alice Johnson"), { timeout: 1500 }); + await user.click(screen.getByText("Alice Johnson")); + + expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1"); + expect(input).toHaveValue(""); + }); + + it("navigates to owner client ?highlight= when a pet result is clicked", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + clients: [], + pets: [{ id: "p1", name: "Bella", breed: null, clientId: "c1", ownerName: "Alice" }], + }), + } as Response); + + renderSearch(); + const user = userEvent.setup({ delay: null }); + const input = screen.getByRole("combobox"); + await user.type(input, "Bella"); + + await waitFor(() => screen.getByText("Bella"), { timeout: 1500 }); + await user.click(screen.getByText("Bella")); + + expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1"); + expect(input).toHaveValue(""); + }); +}); diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 903aad1..0e40212 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; import { PetPhotoUpload } from "../components/PetPhotoUpload.js"; @@ -43,6 +44,7 @@ const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: " // ─── Component ─────────────────────────────────────────────────────────────── export function ClientsPage() { + const [searchParams, setSearchParams] = useSearchParams(); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -50,6 +52,7 @@ export function ClientsPage() { const [selectedClient, setSelectedClient] = useState(null); const [pets, setPets] = useState([]); const [petsLoading, setPetsLoading] = useState(false); + const clientRowRefs = useRef>(new Map()); // Client form const [showClientForm, setShowClientForm] = useState(false); @@ -100,6 +103,23 @@ export function ClientsPage() { .finally(() => setLoading(false)); }, [showDisabled]); + // Auto-select a client when navigated here via GlobalSearch (?highlight=) + useEffect(() => { + const highlightId = searchParams.get("highlight"); + if (!highlightId || loading || clients.length === 0) return; + const match = clients.find((c) => c.id === highlightId); + if (!match) return; + selectClient(match); + const el = clientRowRefs.current.get(highlightId); + if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // Remove the param so back/refresh does not re-trigger + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete("highlight"); + return next; + }, { replace: true }); + }, [searchParams, clients, loading]); // selectClient is stable (defined in render scope) + async function loadPets(clientId: string) { setPetsLoading(true); const r = await fetch(`/api/pets?clientId=${encodeURIComponent(clientId)}`); @@ -398,6 +418,10 @@ export function ClientsPage() { {filtered.map((c) => (
{ + if (el) clientRowRefs.current.set(c.id, el); + else clientRowRefs.current.delete(c.id); + }} onClick={() => selectClient(c)} style={{ padding: "0.5rem 0.6rem", borderRadius: 6, cursor: "pointer", marginBottom: "0.2rem",