1
2
15(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16import { act } from "react";
17import { createRoot, type Root } from "react-dom/client";
18import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
20const mocks = vi.hoisted(() => ({
21 searchOpen: true,
22 setSearchDialog: vi.fn(),
23 recentViewed: vi.fn(),
24 listSavedSearches: vi.fn(),
25 createSavedSearch: vi.fn(),
26 deleteSavedSearch: vi.fn(),
27 pageGet: vi.fn(),
28 dbGet: vi.fn(),
29 push: vi.fn(),
30 searchRecent: vi.fn(),
31 searchQuery: vi.fn(),
32 searchSuggest: vi.fn(),
33 searchRecordRecent: vi.fn()
34}));
35
36vi.mock("@/stores/ui-store", () => ({
37 useUiStore: (sel: (s: Record<string, unknown>) => unknown) =>
38 sel({ searchOpen: mocks.searchOpen, setSearchDialog: mocks.setSearchDialog, searchWorkspaceSlug: "w" })
39}));
40
41vi.mock("@/hooks/use-workspace", () => ({
42 useWorkspace: () => ({ data: { id: "ws1", slug: "w" } })
43}));
44
45vi.mock("next/navigation", () => ({
46 useParams: () => ({ workspace: "w" }),
47 useRouter: () => ({ push: mocks.push })
48}));
49
50vi.mock("@/lib/api", () => ({
51 isApiError: (e: unknown) => typeof e === "object" && e !== null && "code" in (e as object),
52 searchApi: {
53 recent: mocks.searchRecent,
54 query: mocks.searchQuery,
55 suggest: mocks.searchSuggest,
56 recordRecent: mocks.searchRecordRecent
57 },
58 meApi: {
59 recentViewed: mocks.recentViewed,
60 listSavedSearches: mocks.listSavedSearches,
61 createSavedSearch: mocks.createSavedSearch,
62 deleteSavedSearch: mocks.deleteSavedSearch
63 },
64 pageApi: { get: mocks.pageGet },
65 databaseApi: { get: mocks.dbGet }
66}));
67
68vi.mock("../results-list", () => ({
69 ResultsList: () => null,
70 SEARCH_TYPE_ICON: {}
71}));
72
73import { SearchDialog } from "../search-dialog";
74
75let root: Root;
76let host: HTMLElement;
77
78function q(sel: string): Element | null {
79 return host.querySelector(sel);
80}
81function all(sel: string): Element[] {
82 return Array.from(host.querySelectorAll(sel));
83}
84
85
86async function settle() {
87 await act(async () => {
88 await new Promise((r) => setTimeout(r, 350));
89 });
90}
91
92async function setInputValue(el: HTMLInputElement, value: string) {
93 const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
94 await act(async () => {
95 setter.call(el, value);
96 el.dispatchEvent(new Event("input", { bubbles: true }));
97 });
98 await settle();
99}
100
101beforeEach(() => {
102 document.body.innerHTML = "";
103 host = document.createElement("div");
104 document.body.appendChild(host);
105 root = createRoot(host);
106 mocks.searchOpen = true;
107 vi.clearAllMocks();
108
109
110 mocks.searchRecent.mockResolvedValue([]);
111 mocks.searchQuery.mockResolvedValue([]);
112 mocks.searchSuggest.mockResolvedValue([]);
113 mocks.searchRecordRecent.mockResolvedValue([]);
114 mocks.recentViewed.mockResolvedValue([]);
115 mocks.listSavedSearches.mockResolvedValue([]);
116 mocks.createSavedSearch.mockResolvedValue({ id: "s1" });
117 mocks.deleteSavedSearch.mockResolvedValue(undefined);
118});
119
120afterEach(async () => {
121 await act(async () => root.unmount());
122});
123
124describe("SearchDialog home (T-153/T-154)", () => {
125 it("shows the pinned recent-searches section plus the new sections when they have content", async () => {
126 mocks.searchRecent.mockResolvedValue(["quarterly"]);
127 mocks.recentViewed.mockResolvedValue([
128 { id: "r1", itemType: "page", refId: "p1", at: "" },
129 { id: "r2", itemType: "database", refId: "d1", at: "" }
130 ]);
131 mocks.listSavedSearches.mockResolvedValue([
132 { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" }
133 ]);
134 mocks.pageGet.mockResolvedValue({ title: "A Page" });
135
136
137 mocks.dbGet.mockResolvedValue({ database: { title: "A Database" } });
138
139 act(() => root.render(<SearchDialog />));
140 await settle();
141
142
143 expect(q('[data-testid="search-recent"]')).not.toBeNull();
144
145 const viewed = all('[data-testid="search-recent-viewed-item"]');
146 expect(viewed).toHaveLength(2);
147 expect(viewed[0]?.textContent).toContain("A Page");
148 expect(viewed[1]?.textContent).toContain("A Database");
149 expect(mocks.pageGet).toHaveBeenCalledWith("p1");
150 expect(mocks.dbGet).toHaveBeenCalledWith("d1");
151
152 const saved = all('[data-testid="search-saved-item"]');
153 expect(saved).toHaveLength(1);
154 expect(saved[0]?.textContent).toContain("pricing");
155 });
156
157 it("clicking a recently-viewed item navigates (database → /databases/:id, else → /page/:id)", async () => {
158 mocks.recentViewed.mockResolvedValue([
159 { id: "r1", itemType: "page", refId: "p1", at: "" },
160 { id: "r2", itemType: "database", refId: "d1", at: "" }
161 ]);
162 mocks.pageGet.mockResolvedValue({ title: "A Page" });
163
164
165 mocks.dbGet.mockResolvedValue({ database: { title: "A Database" } });
166
167 act(() => root.render(<SearchDialog />));
168 await settle();
169
170 const items = all('[data-testid="search-recent-viewed-item"]');
171 await act(async () => {
172 items[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
173 });
174 expect(mocks.push).toHaveBeenCalledWith("/w/w/page/p1");
175 expect(mocks.setSearchDialog).toHaveBeenCalledWith(false);
176
177
178 await act(async () => root.render(<SearchDialog />));
179 await settle();
180 await act(async () => {
181 all('[data-testid="search-recent-viewed-item"]')[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
182 });
183 expect(mocks.push).toHaveBeenLastCalledWith("/w/w/databases/d1");
184 });
185
186 it("a failed title lookup leaves the chip (icon + Untitled) without breaking the home", async () => {
187 mocks.recentViewed.mockResolvedValue([{ id: "r1", itemType: "page", refId: "gone", at: "" }]);
188 mocks.pageGet.mockRejectedValue(new Error("404"));
189
190 act(() => root.render(<SearchDialog />));
191 await settle();
192
193 const item = q('[data-testid="search-recent-viewed-item"]');
194 expect(item).not.toBeNull();
195 expect(item?.textContent).toContain("Untitled");
196 });
197
198 it("Save persists the committed query as a saved search, then the home lists the chip", async () => {
199 mocks.createSavedSearch.mockResolvedValue({ id: "s1", name: "pricing" });
200
201 mocks.listSavedSearches
202 .mockResolvedValueOnce([])
203 .mockResolvedValue([
204 { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" }
205 ]);
206
207 act(() => root.render(<SearchDialog />));
208 await settle();
209
210 expect(q('[data-testid="search-save-current"]')).toBeNull();
211 expect(all('[data-testid="search-saved-item"]')).toHaveLength(0);
212
213 await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, "pricing");
214 const save = q('[data-testid="search-save-current"]');
215 expect(save).not.toBeNull();
216
217 await act(async () => {
218 (save as HTMLButtonElement).click();
219 await Promise.resolve();
220 await new Promise((r) => setTimeout(r, 50));
221 });
222 expect(mocks.createSavedSearch).toHaveBeenCalledWith({
223 name: "pricing",
224 workspaceId: "ws1",
225 query: { q: "pricing", workspaceId: "ws1" }
226 });
227
228
229 await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, "");
230 expect(all('[data-testid="search-saved-item"]').length).toBe(1);
231 });
232
233 it("clicking a saved chip re-runs its query; the X deletes it", async () => {
234
235 mocks.listSavedSearches.mockResolvedValue([
236 { id: "s1", name: "pricing", workspaceId: "ws1", queryJson: { q: "pricing" }, createdAt: "", updatedAt: "" }
237 ]);
238 mocks.deleteSavedSearch.mockResolvedValue(undefined);
239
240 act(() => root.render(<SearchDialog />));
241 await settle();
242 expect(all('[data-testid="search-saved-item"]')).toHaveLength(1);
243
244
245 await act(async () => {
246 (q('[data-testid="search-saved-item"]') as HTMLButtonElement).click();
247 });
248 expect((q('[data-testid="search-input"]') as HTMLInputElement).value).toBe("pricing");
249
250
251 await setInputValue(q('[data-testid="search-input"]') as HTMLInputElement, "");
252 await act(async () => {
253 (q('[data-testid="search-saved-delete"]') as HTMLButtonElement).click();
254 await Promise.resolve();
255 });
256 expect(mocks.deleteSavedSearch).toHaveBeenCalledWith("s1");
257 expect(q('[data-testid="search-saved-item"]')).toBeNull();
258 });
259});
260