// @vitest-environment jsdom /** * SearchDialog (T-153/T-154) — behavior pins for the ⌘K home's new sections: * * - recently-VIEWED content (a separate store from the recent SEARCHES): the * home lists it with resolved titles; clicking navigates (database → * /databases/:id, everything else → /page/:id); * - saved searches: the "Save" button appears once a query is committed and * persists the query (name + queryJson) via the per-user API; the home * lists the saved chips; clicking a chip re-runs its query; the X deletes. * * The pinned `search-recent` (recent SEARCHES) contract is asserted to still * hold: the section renders in the empty-input home. */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ searchOpen: true, setSearchDialog: vi.fn(), recentViewed: vi.fn(), listSavedSearches: vi.fn(), createSavedSearch: vi.fn(), deleteSavedSearch: vi.fn(), pageGet: vi.fn(), dbGet: vi.fn(), push: vi.fn(), searchRecent: vi.fn(), searchQuery: vi.fn(), searchSuggest: vi.fn(), searchRecordRecent: vi.fn() })); vi.mock("@/stores/ui-store", () => ({ useUiStore: (sel: (s: Record) => unknown) => sel({ searchOpen: mocks.searchOpen, setSearchDialog: mocks.setSearchDialog, searchWorkspaceSlug: "w" }) })); vi.mock("@/hooks/use-workspace", () => ({ useWorkspace: () => ({ data: { id: "ws1", slug: "w" } }) })); vi.mock("next/navigation", () => ({ useParams: () => ({ workspace: "w" }), useRouter: () => ({ push: mocks.push }) })); vi.mock("@/lib/api", () => ({ isApiError: (e: unknown) => typeof e === "object" && e !== null && "code" in (e as object), searchApi: { recent: mocks.searchRecent, query: mocks.searchQuery, suggest: mocks.searchSuggest, recordRecent: mocks.searchRecordRecent }, meApi: { recentViewed: mocks.recentViewed, listSavedSearches: mocks.listSavedSearches, createSavedSearch: mocks.createSavedSearch, deleteSavedSearch: mocks.deleteSavedSearch }, pageApi: { get: mocks.pageGet }, databaseApi: { get: mocks.dbGet } })); vi.mock("../results-list", () => ({ ResultsList: () => null, SEARCH_TYPE_ICON: {} })); import { SearchDialog } from "../search-dialog"; let root: Root; let host: HTMLElement; function q(sel: string): Element | null { return host.querySelector(sel); } function all(sel: string): Element[] { return Array.from(host.querySelectorAll(sel)); } /** Flush the 225 ms input→q debounce (and the effect chains it schedules). */ async function settle() { await act(async () => { await new Promise((r) => setTimeout(r, 350)); }); } async function setInputValue(el: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; await act(async () => { setter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); }); await settle(); } beforeEach(() => { document.body.innerHTML = ""; host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); mocks.searchOpen = true; vi.clearAllMocks(); // Defaults: no recent searches, no saved searches, no recent-viewed. // (clearAllMocks drops implementations, so every fn gets its default here.) mocks.searchRecent.mockResolvedValue([]); mocks.searchQuery.mockResolvedValue([]); mocks.searchSuggest.mockResolvedValue([]); mocks.searchRecordRecent.mockResolvedValue([]); mocks.recentViewed.mockResolvedValue([]); mocks.listSavedSearches.mockResolvedValue([]); mocks.createSavedSearch.mockResolvedValue({ id: "s1" }); mocks.deleteSavedSearch.mockResolvedValue(undefined); }); afterEach(async () => { await act(async () => root.unmount()); }); describe("SearchDialog home (T-153/T-154)", () => { it("shows the pinned recent-searches section plus the new sections when they have content", async () => { mocks.searchRecent.mockResolvedValue(["quarterly"]); mocks.recentViewed.mockResolvedValue([ { id: "r1", itemType: "page", refId: "p1", at: "" }, { id: "r2", itemType: "database", refId: "d1", at: "" } ]); mocks.listSavedSearches.mockResolvedValue([ { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" } ]); mocks.pageGet.mockResolvedValue({ title: "A Page" }); // GET /databases/:id is the NESTED wire shape (see the dialog's title // read) — the mock mirrors it. mocks.dbGet.mockResolvedValue({ database: { title: "A Database" } }); act(() => root.render()); await settle(); // The pinned contract still holds. expect(q('[data-testid="search-recent"]')).not.toBeNull(); // Recently viewed renders with RESOLVED titles (page + database lookups). const viewed = all('[data-testid="search-recent-viewed-item"]'); expect(viewed).toHaveLength(2); expect(viewed[0]?.textContent).toContain("A Page"); expect(viewed[1]?.textContent).toContain("A Database"); expect(mocks.pageGet).toHaveBeenCalledWith("p1"); expect(mocks.dbGet).toHaveBeenCalledWith("d1"); // Saved searches render as chips. const saved = all('[data-testid="search-saved-item"]'); expect(saved).toHaveLength(1); expect(saved[0]?.textContent).toContain("pricing"); }); it("clicking a recently-viewed item navigates (database → /databases/:id, else → /page/:id)", async () => { mocks.recentViewed.mockResolvedValue([ { id: "r1", itemType: "page", refId: "p1", at: "" }, { id: "r2", itemType: "database", refId: "d1", at: "" } ]); mocks.pageGet.mockResolvedValue({ title: "A Page" }); // GET /databases/:id is the NESTED wire shape (see the dialog's title // read) — the mock mirrors it. mocks.dbGet.mockResolvedValue({ database: { title: "A Database" } }); act(() => root.render()); await settle(); const items = all('[data-testid="search-recent-viewed-item"]'); await act(async () => { items[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mocks.push).toHaveBeenCalledWith("/w/w/page/p1"); expect(mocks.setSearchDialog).toHaveBeenCalledWith(false); // Re-open for the database item. await act(async () => root.render()); await settle(); await act(async () => { all('[data-testid="search-recent-viewed-item"]')[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mocks.push).toHaveBeenLastCalledWith("/w/w/databases/d1"); }); it("a failed title lookup leaves the chip (icon + Untitled) without breaking the home", async () => { mocks.recentViewed.mockResolvedValue([{ id: "r1", itemType: "page", refId: "gone", at: "" }]); mocks.pageGet.mockRejectedValue(new Error("404")); act(() => root.render()); await settle(); const item = q('[data-testid="search-recent-viewed-item"]'); expect(item).not.toBeNull(); expect(item?.textContent).toContain("Untitled"); }); it("Save persists the committed query as a saved search, then the home lists the chip", async () => { mocks.createSavedSearch.mockResolvedValue({ id: "s1", name: "pricing" }); // Mount fetch → none yet; the post-save refresh → the new chip. mocks.listSavedSearches .mockResolvedValueOnce([]) .mockResolvedValue([ { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" } ]); act(() => root.render()); await settle(); // No committed query yet → no Save button; no chip either. expect(q('[data-testid="search-save-current"]')).toBeNull(); expect(all('[data-testid="search-saved-item"]')).toHaveLength(0); await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, "pricing"); const save = q('[data-testid="search-save-current"]'); expect(save).not.toBeNull(); await act(async () => { (save as HTMLButtonElement).click(); await Promise.resolve(); await new Promise((r) => setTimeout(r, 50)); }); expect(mocks.createSavedSearch).toHaveBeenCalledWith({ name: "pricing", workspaceId: "ws1", query: { q: "pricing", workspaceId: "ws1" } }); // The save's refresh listed the new chip; clear the input → the home shows it. await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, ""); expect(all('[data-testid="search-saved-item"]').length).toBe(1); }); it("clicking a saved chip re-runs its query; the X deletes it", async () => { // Mount WITH the chip already saved (the home shows it on open). mocks.listSavedSearches.mockResolvedValue([ { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" } ]); mocks.deleteSavedSearch.mockResolvedValue(undefined); act(() => root.render()); await settle(); expect(all('[data-testid="search-saved-item"]')).toHaveLength(1); // Apply: the chip's query lands in the input (re-running the search). await act(async () => { (q('[data-testid="search-saved-item"]') as HTMLButtonElement).click(); }); expect((q('[data-testid="search-input"]') as HTMLInputElement).value).toBe("pricing"); // Back to the home (clear the input) and delete: the chip leaves it. await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, ""); await act(async () => { (q('[data-testid="search-saved-delete"]') as HTMLButtonElement).click(); await Promise.resolve(); }); expect(mocks.deleteSavedSearch).toHaveBeenCalledWith("s1"); expect(q('[data-testid="search-saved-item"]')).toBeNull(); }); });