mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Company search is the primary way operators find issues, comments, documents, artifacts, agents, and projects across a busy company > - Search previously supported only a bare text query: no way to narrow by status/assignee/project/label/date, no sort control, no typed operators, and weak relevance/snippets meant hunting through noise > - As companies accumulate tens of thousands of items, unfiltered single-sort search stops scaling for day-to-day operator workflows > - This pull request adds a full filtering model (filter bar, chips, mobile sheet, URL state), sort modes, typed query operators (`status:`, `assignee:`, `type:`, …) with command-palette parity, relevance/snippet/deep-link improvements, zero-results recovery, and the supporting shared validators, backend service work, and DB indexes > - The benefit is that operators can go from a vague query to the exact item in a couple of keystrokes, on desktop and mobile, with shareable filtered-search URLs ## Linked Issues or Issue Description No existing public GitHub issue; describing the underlying feature request inline (per feature_request template): - **Problem:** Company search accepted only a plain text query. Users could not filter results by status, assignee, project, label, or recency; could not change result ordering; and got no guidance when filters emptied the result set. - **Desired solution:** Structured search filters (UI controls + typed query operators + URL parameters), selectable sort modes, better relevance and snippets with exact deep links, and parity between the search page and the command palette. - **Alternatives considered:** Client-side filtering of unfiltered results (does not scale past the fetch limit); a separate "advanced search" page (splits the surface and duplicates state handling). Related (not duplicate) PRs found while searching: #4848 (issue search query planning), #8235 (search rate limiting). ## What Changed - **Shared contract:** new search filter/sort/count/zero-results types and validators in `packages/shared` (`validators/search.ts`, types index). - **Backend:** `server/src/services/company-search.ts` supports issue filters, sort modes, per-filter option counts, snippets, artifact visibility, and zero-results loosen suggestions; single-statement match replaces per-scope scans and predicates are trigram-index compatible (~3.7s → ~350ms on a live 14.8k-hit corpus). - **DB:** migration `0142_company_search_sort_indexes.sql` adds the supporting indexes. - **Search page (`ui/src/pages/Search.tsx`):** filter bar, removable chips, mobile filter sheet with result-count preview, sort menu, URL round-tripping, zero-results recovery UI. - **Query operators (`ui/src/lib/search-query-parser.ts`):** typed operators parsed into filters, operator autocomplete, filter pills. - **Command palette:** operator-aware parsing and full-search handoff. - **Stale-operator fix (latest commit):** typed operator filters are no longer folded into persistent URL-filter state, so deleting a token (e.g. removing `status:blocked` from the input) actually removes the filter from subsequent requests; filter-control edits materialize control state and strip typed tokens so a removed chip cannot resurrect from the input. ## Verification - `cd ui && npx vitest run src/pages/Search.test.tsx` — 19 tests including two new red→green regressions for the stale-operator paths (both fail on the previous commit, pass now). - `cd ui && npx vitest run src/components/CommandPalette.test.tsx` and `cd server && npx vitest run src/services/company-search-service.test.ts` — operator parity and backend filter/sort/count coverage. - `cd ui && npx tsc --noEmit` — clean. - Manual: open `/search`, type `auth status:blocked`, confirm the status filter applies; delete `status:blocked`, confirm results are unfiltered again; drive the same filters from the filter bar/chips/mobile sheet and confirm the URL round-trips (reload/back/forward preserves state). - Full end-to-end QA pass (9/9 acceptance checks) against the wireframes on desktop (1280px) and mobile (390px) with a live API and browser automation. ## Risks - Additive migration (indexes only, no data rewrites) — safe to roll forward; index creation cost is paid once at migrate time. - Search request shape gains optional parameters only; old clients keep working. - Behavioral shift: filter-control edits now strip typed operator tokens from the query text (their values persist as filter state) — deliberate, so removed filters stay removed. - Ranking changes alter result ordering for existing queries; covered by service tests and the QA pass. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic, extended thinking + tool use) — stale-operator-filter fix, regression tests, PR preparation. - GPT-5 Codex (`codex_local` adapter) and Claude Opus 4.6 (`claude-opus-4-6`) — earlier implementation phases (backend contract, filter UI, operators, ranking) under agent orchestration. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details — pre-existing branch name retained to avoid closing/reopening the PR - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no user-facing docs affected) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending re-run on latest commit) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending re-review of the stale-filter fix) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
991 lines
29 KiB
TypeScript
991 lines
29 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import type { ReactNode } from "react";
|
||
import { flushSync } from "react-dom";
|
||
import { createRoot } from "react-dom/client";
|
||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { Search, buildSearchUrl } from "./Search";
|
||
|
||
const companyState = vi.hoisted(() => ({
|
||
selectedCompanyId: "company-1",
|
||
}));
|
||
|
||
const breadcrumbState = vi.hoisted(() => ({
|
||
setBreadcrumbs: vi.fn(),
|
||
}));
|
||
|
||
const dialogState = vi.hoisted(() => ({
|
||
openNewIssue: vi.fn(),
|
||
}));
|
||
|
||
const navigateMock = vi.hoisted(() => vi.fn());
|
||
|
||
const searchApiMock = vi.hoisted(() => ({
|
||
search: vi.fn(),
|
||
}));
|
||
|
||
const agentsApiMock = vi.hoisted(() => ({
|
||
list: vi.fn(),
|
||
}));
|
||
|
||
const projectsApiMock = vi.hoisted(() => ({
|
||
list: vi.fn(),
|
||
}));
|
||
|
||
const issuesApiMock = vi.hoisted(() => ({
|
||
listLabels: vi.fn(),
|
||
}));
|
||
|
||
const authApiMock = vi.hoisted(() => ({
|
||
getSession: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("../context/CompanyContext", () => ({
|
||
useCompany: () => companyState,
|
||
}));
|
||
|
||
vi.mock("../context/BreadcrumbContext", () => ({
|
||
useBreadcrumbs: () => breadcrumbState,
|
||
}));
|
||
|
||
vi.mock("../context/DialogContext", () => ({
|
||
useDialogActions: () => dialogState,
|
||
}));
|
||
|
||
vi.mock("../context/SidebarContext", () => ({
|
||
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
|
||
}));
|
||
|
||
vi.mock("../api/search", () => ({
|
||
searchApi: searchApiMock,
|
||
}));
|
||
|
||
vi.mock("../api/agents", () => ({
|
||
agentsApi: agentsApiMock,
|
||
}));
|
||
|
||
vi.mock("../api/projects", () => ({
|
||
projectsApi: projectsApiMock,
|
||
}));
|
||
|
||
vi.mock("../api/issues", () => ({
|
||
issuesApi: issuesApiMock,
|
||
}));
|
||
|
||
vi.mock("../api/auth", () => ({
|
||
authApi: authApiMock,
|
||
}));
|
||
|
||
vi.mock("@/lib/router", async () => {
|
||
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
||
return {
|
||
...actual,
|
||
useNavigate: () => navigateMock,
|
||
};
|
||
});
|
||
|
||
vi.mock("../components/StatusIcon", () => ({
|
||
StatusIcon: ({ status }: { status: string }) => <span data-status={status} />,
|
||
}));
|
||
|
||
vi.mock("../components/StatusBadge", () => ({
|
||
StatusBadge: ({ status }: { status: string }) => <span data-status-badge={status}>{status}</span>,
|
||
}));
|
||
|
||
vi.mock("../components/Identity", () => ({
|
||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||
}));
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||
|
||
async function flush() {
|
||
await Promise.resolve();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
|
||
async function waitForAssertion(assertion: () => void, attempts = 50) {
|
||
let lastError: unknown;
|
||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||
try {
|
||
assertion();
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
await flush();
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
function renderSearch(initialPath: string, container: HTMLDivElement, node?: ReactNode) {
|
||
const root = createRoot(container);
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: false } },
|
||
});
|
||
flushSync(() => {
|
||
root.render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<MemoryRouter initialEntries={[initialPath]}>
|
||
<Routes>
|
||
<Route path="/search" element={node ?? <Search />} />
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
return { root, queryClient };
|
||
}
|
||
|
||
describe("buildSearchUrl", () => {
|
||
it("writes q and scope when provided", () => {
|
||
expect(buildSearchUrl("http://x/search", "auth flake", "comments")).toBe(
|
||
"/search?q=auth+flake&scope=comments",
|
||
);
|
||
});
|
||
|
||
it("clears q when empty and omits scope when scope=all", () => {
|
||
expect(buildSearchUrl("http://x/search?q=stale&scope=issues", "", "all")).toBe("/search");
|
||
});
|
||
|
||
it("preserves the existing pathname and hash", () => {
|
||
expect(buildSearchUrl("http://x/PAP/search?q=x#anchor", "y", "issues")).toBe(
|
||
"/PAP/search?q=y&scope=issues#anchor",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Search page", () => {
|
||
let container: HTMLDivElement;
|
||
|
||
beforeEach(() => {
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
breadcrumbState.setBreadcrumbs.mockReset();
|
||
dialogState.openNewIssue.mockReset();
|
||
navigateMock.mockReset();
|
||
searchApiMock.search.mockReset();
|
||
agentsApiMock.list.mockReset();
|
||
projectsApiMock.list.mockReset();
|
||
issuesApiMock.listLabels.mockReset();
|
||
authApiMock.getSession.mockReset();
|
||
agentsApiMock.list.mockResolvedValue([]);
|
||
projectsApiMock.list.mockResolvedValue([]);
|
||
issuesApiMock.listLabels.mockResolvedValue([]);
|
||
authApiMock.getSession.mockResolvedValue({ user: { id: "user-1" }, session: { userId: "user-1" } });
|
||
window.localStorage.clear();
|
||
});
|
||
|
||
afterEach(() => {
|
||
container.remove();
|
||
});
|
||
|
||
it("issues a search request when ?q is in the URL and renders the result", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "auth flake",
|
||
normalizedQuery: "auth flake",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 1, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [
|
||
{
|
||
id: "issue-1",
|
||
type: "issue",
|
||
score: 100,
|
||
title: "PAP-3142 Auth middleware flakes",
|
||
href: "/PAP/issues/PAP-3142",
|
||
matchedFields: ["title", "comment"],
|
||
sourceLabel: "Comment",
|
||
snippet: "we hit another flake",
|
||
snippets: [
|
||
{
|
||
field: "title",
|
||
label: "Title",
|
||
text: "Auth middleware flakes",
|
||
highlights: [{ start: 0, end: 4 }],
|
||
},
|
||
{
|
||
field: "comment",
|
||
label: "Comment",
|
||
text: "we hit another flake in the morning batch",
|
||
highlights: [{ start: 16, end: 21 }],
|
||
},
|
||
],
|
||
issue: {
|
||
id: "issue-1",
|
||
identifier: "PAP-3142",
|
||
title: "Auth middleware flakes",
|
||
status: "in_progress",
|
||
priority: "medium",
|
||
assigneeAgentId: null,
|
||
assigneeUserId: null,
|
||
projectId: null,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=auth+flake", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth flake",
|
||
scope: "all",
|
||
limit: 20,
|
||
});
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("PAP-3142");
|
||
expect(container.textContent).toContain("Auth middleware flakes");
|
||
expect(container.textContent).toContain("1 result");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders artifact search results in the company search surface", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "launch brief",
|
||
normalizedQuery: "launch brief",
|
||
scope: "artifacts",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 1, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [
|
||
{
|
||
id: "document:artifact-1",
|
||
type: "artifact",
|
||
score: 140,
|
||
title: "Launch Artifact Brief",
|
||
href: "/PAP/issues/PAP-42#document-brief",
|
||
matchedFields: ["artifact"],
|
||
sourceLabel: "Artifact",
|
||
snippet: "launch brief preview text",
|
||
snippets: [
|
||
{
|
||
field: "artifact",
|
||
label: "Artifact",
|
||
text: "launch brief preview text",
|
||
highlights: [{ start: 0, end: 6 }],
|
||
},
|
||
],
|
||
artifact: {
|
||
id: "document:artifact-1",
|
||
source: "document",
|
||
mediaKind: "document",
|
||
issueId: "issue-42",
|
||
issueIdentifier: "PAP-42",
|
||
issueTitle: "Ship launch artifacts",
|
||
projectId: null,
|
||
projectName: null,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
updatedAt: new Date().toISOString(),
|
||
previewImageUrl: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=launch+brief&scope=artifacts", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "launch brief",
|
||
scope: "artifacts",
|
||
limit: 20,
|
||
});
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("Launch Artifact Brief");
|
||
expect(container.textContent).toContain("PAP-42");
|
||
expect(container.textContent).toContain("launch brief preview text");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders comment and document result rows with exact anchors, source chips, and highlights", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "needle",
|
||
normalizedQuery: "needle",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 1, document: 1, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [
|
||
{
|
||
id: "issue-comment",
|
||
type: "issue",
|
||
score: 180,
|
||
title: "PAP-77 Comment source",
|
||
href: "/PAP/issues/PAP-77#comment-comment-77",
|
||
matchedFields: ["comment"],
|
||
sourceLabel: "Comment",
|
||
snippet: "thread needle evidence",
|
||
snippets: [
|
||
{
|
||
field: "comment",
|
||
label: "Comment",
|
||
text: "thread needle evidence",
|
||
highlights: [{ start: 7, end: 13 }],
|
||
},
|
||
],
|
||
issue: {
|
||
id: "issue-comment",
|
||
identifier: "PAP-77",
|
||
title: "Comment source",
|
||
status: "todo",
|
||
priority: "medium",
|
||
assigneeAgentId: null,
|
||
assigneeUserId: null,
|
||
projectId: null,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
updatedAt: new Date().toISOString(),
|
||
previewImageUrl: null,
|
||
},
|
||
{
|
||
id: "issue-document",
|
||
type: "issue",
|
||
score: 170,
|
||
title: "PAP-78 Document source",
|
||
href: "/PAP/issues/PAP-78#document-plan",
|
||
matchedFields: ["document"],
|
||
sourceLabel: "Plan",
|
||
snippet: "plan needle evidence",
|
||
snippets: [
|
||
{
|
||
field: "document",
|
||
label: "Plan",
|
||
text: "plan needle evidence",
|
||
highlights: [{ start: 5, end: 11 }],
|
||
},
|
||
],
|
||
issue: {
|
||
id: "issue-document",
|
||
identifier: "PAP-78",
|
||
title: "Document source",
|
||
status: "todo",
|
||
priority: "medium",
|
||
assigneeAgentId: null,
|
||
assigneeUserId: null,
|
||
projectId: null,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
updatedAt: new Date().toISOString(),
|
||
previewImageUrl: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=needle", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.querySelector('a[href="/PAP/issues/PAP-77#comment-comment-77"]')).not.toBeNull();
|
||
expect(container.querySelector('a[href="/PAP/issues/PAP-78#document-plan"]')).not.toBeNull();
|
||
expect(container.textContent).toContain("Comment");
|
||
expect(container.textContent).toContain("Doc");
|
||
expect(container.querySelectorAll("mark")).toHaveLength(2);
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders the explicit loading state while search is pending", async () => {
|
||
searchApiMock.search.mockReturnValueOnce(new Promise(() => {}));
|
||
|
||
const { root } = renderSearch("/search?q=slow", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.querySelector('[data-testid="search-loading"]')?.textContent).toContain("slow");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders the explicit error state with retry and fallback actions", async () => {
|
||
searchApiMock.search.mockRejectedValueOnce(Object.assign(new Error("Search failed"), { status: 500 }));
|
||
|
||
const { root } = renderSearch("/search?q=broken", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("Couldn’t run that search");
|
||
expect(container.textContent).toContain("The server returned 500.");
|
||
expect(container.textContent).toContain("Retry");
|
||
expect(container.textContent).toContain("Open Tasks filter view");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("debounces typing into the input and dispatches a search after the debounce window", async () => {
|
||
searchApiMock.search.mockResolvedValue({
|
||
query: "deflake",
|
||
normalizedQuery: "deflake",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
});
|
||
|
||
const { root } = renderSearch("/search", container);
|
||
|
||
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
|
||
expect(input).not.toBeNull();
|
||
|
||
flushSync(() => {
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||
HTMLInputElement.prototype,
|
||
"value",
|
||
)!.set!;
|
||
nativeSetter.call(input, "deflake");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
// The debounce hasn't fired yet, so no API call should be made synchronously.
|
||
expect(searchApiMock.search).not.toHaveBeenCalled();
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "deflake",
|
||
scope: "all",
|
||
limit: 20,
|
||
});
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("auto-redirects an exact identifier match to the issue root, dropping any deep-link suffix", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "PAP-3366",
|
||
normalizedQuery: "pap-3366",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 1, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [
|
||
{
|
||
id: "issue-3366",
|
||
type: "issue",
|
||
score: 1300,
|
||
title: "PAP-3366 Continuation summary",
|
||
href: "/PAP/issues/PAP-3366#document-continuation-summary",
|
||
matchedFields: ["identifier", "document"],
|
||
sourceLabel: "Document",
|
||
snippet: "Continuation summary excerpt",
|
||
snippets: [
|
||
{
|
||
field: "document",
|
||
label: "Continuation summary",
|
||
text: "Continuation summary excerpt",
|
||
highlights: [],
|
||
},
|
||
],
|
||
issue: {
|
||
id: "issue-3366",
|
||
identifier: "PAP-3366",
|
||
title: "Continuation summary",
|
||
status: "in_progress",
|
||
priority: "medium",
|
||
assigneeAgentId: null,
|
||
assigneeUserId: null,
|
||
projectId: null,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=PAP-3366", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(navigateMock).toHaveBeenCalledWith("/PAP/issues/PAP-3366", { replace: true });
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders the no-results state with a Search-all action when scope is non-default", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "ghost",
|
||
normalizedQuery: "ghost",
|
||
scope: "comments",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=ghost&scope=comments", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("No results for");
|
||
expect(container.textContent).toContain("ghost");
|
||
expect(container.textContent).toContain("Search all scopes");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("parses URL filters into search params and operator pills", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce({
|
||
query: "auth",
|
||
normalizedQuery: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
});
|
||
|
||
const { root } = renderSearch("/search?q=auth&status=todo&updatedWithin=7d", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
status: ["todo"],
|
||
updatedWithin: "7d",
|
||
});
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("status:todo");
|
||
expect(container.textContent).toContain("updated:>7d");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("parses typed operators before dispatching search", async () => {
|
||
searchApiMock.search.mockResolvedValue({
|
||
query: "auth",
|
||
normalizedQuery: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
});
|
||
|
||
const { root } = renderSearch("/search", container);
|
||
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
|
||
|
||
flushSync(() => {
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||
nativeSetter.call(input, "auth status:blocked");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
status: ["blocked"],
|
||
});
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("status:blocked");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("drops a committed operator filter from requests when its token is deleted", async () => {
|
||
searchApiMock.search.mockResolvedValue(emptyResponse());
|
||
|
||
const { root } = renderSearch("/search", container);
|
||
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||
|
||
flushSync(() => {
|
||
nativeSetter.call(input, "auth status:blocked");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
status: ["blocked"],
|
||
});
|
||
});
|
||
|
||
// Deleting the operator token must also delete its filter from the request.
|
||
flushSync(() => {
|
||
nativeSetter.call(input, "auth");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
await waitForAssertion(() => {
|
||
const lastCall = searchApiMock.search.mock.calls.at(-1);
|
||
expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 });
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("removes an operator-derived filter chip and strips its token from the query", async () => {
|
||
searchApiMock.search.mockResolvedValue(emptyResponse());
|
||
|
||
const { root } = renderSearch("/search", container);
|
||
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||
|
||
flushSync(() => {
|
||
nativeSetter.call(input, "auth status:blocked");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
status: ["blocked"],
|
||
});
|
||
});
|
||
|
||
const removeButton = await (async () => {
|
||
let button: HTMLButtonElement | null = null;
|
||
await waitForAssertion(() => {
|
||
button = container.querySelector<HTMLButtonElement>('button[aria-label="Remove filter Status: Blocked"]');
|
||
expect(button).not.toBeNull();
|
||
});
|
||
return button!;
|
||
})();
|
||
|
||
flushSync(() => {
|
||
removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
// The chip removal wins over the typed token: the input keeps only the plain
|
||
// text and the re-query carries no status filter.
|
||
await waitForAssertion(() => {
|
||
expect(input.value).toBe("auth");
|
||
const lastCall = searchApiMock.search.mock.calls.at(-1);
|
||
expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 });
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("shows operator autocomplete suggestions and applies one to the current token", async () => {
|
||
searchApiMock.search.mockResolvedValue({
|
||
query: "auth",
|
||
normalizedQuery: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
});
|
||
|
||
const { root } = renderSearch("/search", container);
|
||
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
|
||
expect(input).not.toBeNull();
|
||
|
||
flushSync(() => {
|
||
input.focus();
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||
nativeSetter.call(input, "auth sta");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
let suggestionButton: HTMLButtonElement | null = null;
|
||
await waitForAssertion(() => {
|
||
const suggestions = container.querySelector('[data-testid="search-operator-suggestions"]');
|
||
expect(suggestions).not.toBeNull();
|
||
expect(suggestions!.textContent).toContain("status:todo");
|
||
expect(suggestions!.textContent).toContain("status:blocked");
|
||
expect(suggestions!.textContent).not.toContain("assignee:me");
|
||
suggestionButton = container.querySelector('button[aria-label="Insert operator status:todo"]');
|
||
expect(suggestionButton).not.toBeNull();
|
||
});
|
||
|
||
flushSync(() => {
|
||
suggestionButton!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||
suggestionButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(input.value).toBe("auth status:todo");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
function emptyResponse(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
query: "auth",
|
||
normalizedQuery: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
offset: 0,
|
||
sort: "relevance",
|
||
countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 },
|
||
filterOptionCounts: {
|
||
status: {},
|
||
priority: {},
|
||
assigneeAgentId: {},
|
||
assigneeUserId: {},
|
||
projectId: {},
|
||
labelId: {},
|
||
updatedWithin: {},
|
||
},
|
||
zeroResults: null,
|
||
hasMore: false,
|
||
results: [],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
it("round-trips the sort param through the URL and into the search request", async () => {
|
||
searchApiMock.search.mockResolvedValue(emptyResponse({ sort: "updated" }));
|
||
|
||
const { root } = renderSearch("/search?q=auth&sort=updated", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
sort: "updated",
|
||
});
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
// The Sort menu trigger reflects the active sort.
|
||
expect(container.textContent).toContain("Recently updated");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders a removable filter chip and re-queries without the filter when removed", async () => {
|
||
searchApiMock.search.mockResolvedValue(emptyResponse());
|
||
|
||
const { root } = renderSearch("/search?q=auth&status=todo", container);
|
||
|
||
// First request carries the status filter from the URL.
|
||
await waitForAssertion(() => {
|
||
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
|
||
q: "auth",
|
||
scope: "all",
|
||
limit: 20,
|
||
status: ["todo"],
|
||
});
|
||
});
|
||
|
||
// A removable chip is rendered for the active filter.
|
||
const removeButton = await (async () => {
|
||
let button: HTMLButtonElement | null = null;
|
||
await waitForAssertion(() => {
|
||
button = container.querySelector<HTMLButtonElement>('button[aria-label="Remove filter Status: Todo"]');
|
||
expect(button).not.toBeNull();
|
||
});
|
||
return button!;
|
||
})();
|
||
|
||
flushSync(() => {
|
||
removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
// After removal the search re-fires with no status filter.
|
||
await waitForAssertion(() => {
|
||
const lastCall = searchApiMock.search.mock.calls.at(-1);
|
||
expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 });
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
it("renders zero-results recovery with loosen suggestions when filters empty the page", async () => {
|
||
searchApiMock.search.mockResolvedValueOnce(
|
||
emptyResponse({
|
||
zeroResults: {
|
||
unfilteredTotal: 12,
|
||
loosenSuggestions: [
|
||
{ filter: "status", values: ["done"], resultCount: 12, additionalCount: 12 },
|
||
],
|
||
},
|
||
}),
|
||
);
|
||
|
||
const { root } = renderSearch("/search?q=auth&status=done", container);
|
||
|
||
await waitForAssertion(() => {
|
||
expect(container.querySelector('[data-testid="search-zero-results-recovery"]')).not.toBeNull();
|
||
expect(container.textContent).toContain("No results with these filters");
|
||
expect(container.textContent).toContain("12 results match");
|
||
expect(container.textContent).toContain("Loosen a filter");
|
||
expect(container.textContent).toContain("+12 results");
|
||
expect(container.textContent).toContain("Clear all filters");
|
||
});
|
||
|
||
flushSync(() => {
|
||
root.unmount();
|
||
});
|
||
});
|
||
|
||
});
|