feat(search): filters, sorting, operators & command-palette parity (#9327)
## 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>
This commit is contained in:
parent
cec0fc249a
commit
606aa4f266
|
|
@ -0,0 +1,3 @@
|
|||
CREATE INDEX IF NOT EXISTS "issues_company_updated_idx" ON "issues" ("company_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issues_company_created_idx" ON "issues" ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issues_company_priority_idx" ON "issues" ("company_id","priority");
|
||||
|
|
@ -981,6 +981,13 @@
|
|||
"when": 1783555301000,
|
||||
"tag": "0141_heartbeat_runs_company_created_at_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1783555301100,
|
||||
"tag": "0142_company_search_sort_indexes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ export const issues = pgTable(
|
|||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
|
||||
companyUpdatedIdx: index("issues_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||
companyCreatedIdx: index("issues_company_created_idx").on(table.companyId, table.createdAt),
|
||||
companyPriorityIdx: index("issues_company_priority_idx").on(table.companyId, table.priority),
|
||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
||||
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
||||
|
|
|
|||
|
|
@ -583,14 +583,21 @@ export type {
|
|||
ProjectGoalRef,
|
||||
ProjectManagedByPlugin,
|
||||
ProjectWorkspace,
|
||||
CompanySearchCountType,
|
||||
CompanySearchFilterOptionCounts,
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchIssueFilterKey,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
CompanySearchResultType,
|
||||
CompanySearchScope,
|
||||
CompanySearchSnippet,
|
||||
CompanySearchSort,
|
||||
CompanySearchUpdatedWithinOption,
|
||||
CompanySearchZeroResults,
|
||||
CompanySearchZeroResultsLoosenSuggestion,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceConfig,
|
||||
|
|
@ -994,7 +1001,7 @@ export type {
|
|||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
export { COMPANY_SEARCH_SCOPES } from "./types/index.js";
|
||||
export { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS, COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS } from "./types/index.js";
|
||||
export {
|
||||
ISSUE_REFERENCE_IDENTIFIER_RE,
|
||||
buildIssueReferenceHref,
|
||||
|
|
|
|||
|
|
@ -219,16 +219,23 @@ export type {
|
|||
} from "./document-annotation.js";
|
||||
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
CompanySearchCountType,
|
||||
CompanySearchFilterOptionCounts,
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchIssueFilterKey,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
CompanySearchResultType,
|
||||
CompanySearchScope,
|
||||
CompanySearchSnippet,
|
||||
CompanySearchSort,
|
||||
CompanySearchUpdatedWithinOption,
|
||||
CompanySearchZeroResults,
|
||||
CompanySearchZeroResultsLoosenSuggestion,
|
||||
} from "./search.js";
|
||||
export { COMPANY_SEARCH_SCOPES } from "./search.js";
|
||||
export { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS, COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS } from "./search.js";
|
||||
export type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,23 @@ import type { IssuePriority, IssueStatus } from "../constants.js";
|
|||
export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const;
|
||||
export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number];
|
||||
|
||||
export const COMPANY_SEARCH_SORTS = ["relevance", "updated", "created", "priority"] as const;
|
||||
export type CompanySearchSort = (typeof COMPANY_SEARCH_SORTS)[number];
|
||||
|
||||
export const COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS = ["24h", "7d", "30d", "90d"] as const;
|
||||
export type CompanySearchUpdatedWithinOption = (typeof COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS)[number];
|
||||
|
||||
export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project";
|
||||
export type CompanySearchCountType = CompanySearchResultType | "comment" | "document";
|
||||
export type CompanySearchIssueFilterKey =
|
||||
| "status"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
| "projectId"
|
||||
| "labelId"
|
||||
| "priority"
|
||||
| "updatedWithin"
|
||||
| "updatedAfter";
|
||||
|
||||
export interface CompanySearchHighlight {
|
||||
start: number;
|
||||
|
|
@ -57,13 +73,38 @@ export interface CompanySearchResult {
|
|||
previewImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySearchFilterOptionCounts {
|
||||
status: Partial<Record<IssueStatus, number>>;
|
||||
priority: Partial<Record<IssuePriority, number>>;
|
||||
assigneeAgentId: Record<string, number>;
|
||||
assigneeUserId: Record<string, number>;
|
||||
projectId: Record<string, number>;
|
||||
labelId: Record<string, number>;
|
||||
updatedWithin: Partial<Record<CompanySearchUpdatedWithinOption, number>>;
|
||||
}
|
||||
|
||||
export interface CompanySearchZeroResultsLoosenSuggestion {
|
||||
filter: CompanySearchIssueFilterKey;
|
||||
values: string[];
|
||||
resultCount: number;
|
||||
additionalCount: number;
|
||||
}
|
||||
|
||||
export interface CompanySearchZeroResults {
|
||||
unfilteredTotal: number;
|
||||
loosenSuggestions: CompanySearchZeroResultsLoosenSuggestion[];
|
||||
}
|
||||
|
||||
export interface CompanySearchResponse {
|
||||
query: string;
|
||||
normalizedQuery: string;
|
||||
scope: CompanySearchScope;
|
||||
sort: CompanySearchSort;
|
||||
limit: number;
|
||||
offset: number;
|
||||
results: CompanySearchResult[];
|
||||
countsByType: Record<CompanySearchResultType, number>;
|
||||
countsByType: Record<CompanySearchCountType, number>;
|
||||
filterOptionCounts: CompanySearchFilterOptionCounts;
|
||||
zeroResults: CompanySearchZeroResults | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import { COMPANY_SEARCH_SCOPES } from "../types/search.js";
|
||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||
import { isUuidLike } from "../agent-url-key.js";
|
||||
import { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS } from "../types/search.js";
|
||||
|
||||
export const COMPANY_SEARCH_MAX_QUERY_LENGTH = 200;
|
||||
export const COMPANY_SEARCH_MAX_TOKENS = 8;
|
||||
|
|
@ -7,31 +9,174 @@ export const COMPANY_SEARCH_DEFAULT_LIMIT = 20;
|
|||
export const COMPANY_SEARCH_MAX_LIMIT = 50;
|
||||
export const COMPANY_SEARCH_MAX_OFFSET = 200;
|
||||
|
||||
const UPDATED_WITHIN_RE = /^[1-9]\d{0,2}(h|d|w|m)$/;
|
||||
|
||||
function firstQueryValue(value: unknown): unknown {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
function queryValues(value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown, ctx: z.RefinementCtx, field: string): string | undefined {
|
||||
const raw = firstQueryValue(value);
|
||||
const numeric = typeof raw === "number"
|
||||
? raw
|
||||
: typeof raw === "string" && raw.trim().length > 0
|
||||
? Number.parseInt(raw, 10)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(numeric)));
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== "string" && typeof raw !== "number") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a string` });
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(raw).trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function parseIntegerQuery(
|
||||
value: unknown,
|
||||
ctx: z.RefinementCtx,
|
||||
field: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const raw = firstQueryValue(value);
|
||||
if (raw === undefined || raw === null || raw === "") return fallback;
|
||||
const text = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
|
||||
if (!/^-?\d+$/.test(text)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be an integer` });
|
||||
return fallback;
|
||||
}
|
||||
const numeric = Number.parseInt(text, 10);
|
||||
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
|
||||
const range = min === 0 ? `between 0 and ${max}` : `between ${min} and ${max}`;
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be ${range}` });
|
||||
return fallback;
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function parseEnumList<T extends string>(
|
||||
value: unknown,
|
||||
ctx: z.RefinementCtx,
|
||||
field: string,
|
||||
allowed: readonly T[],
|
||||
): T[] {
|
||||
const allowedSet = new Set<string>(allowed);
|
||||
const values: T[] = [];
|
||||
for (const rawEntry of queryValues(value)) {
|
||||
if (typeof rawEntry !== "string") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a comma-separated string` });
|
||||
continue;
|
||||
}
|
||||
for (const rawItem of rawEntry.split(",")) {
|
||||
const item = rawItem.trim();
|
||||
if (!item) continue;
|
||||
if (!allowedSet.has(item)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} contains an unsupported value` });
|
||||
continue;
|
||||
}
|
||||
if (!values.includes(item as T)) values.push(item as T);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseOptionalUuid(value: unknown, ctx: z.RefinementCtx, field: string): string | undefined {
|
||||
const normalized = parseOptionalString(value, ctx, field);
|
||||
if (normalized === undefined) return undefined;
|
||||
if (!isUuidLike(normalized)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a UUID` });
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseAssigneeAgentId(value: unknown, ctx: z.RefinementCtx): string | null | undefined {
|
||||
const normalized = parseOptionalString(value, ctx, "assigneeAgentId");
|
||||
if (normalized === undefined) return undefined;
|
||||
if (normalized.toLowerCase() === "null") return null;
|
||||
if (!isUuidLike(normalized)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "assigneeAgentId must be a UUID or 'null'" });
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseUpdatedAfter(value: unknown, ctx: z.RefinementCtx): string | undefined {
|
||||
const normalized = parseOptionalString(value, ctx, "updatedAfter");
|
||||
if (normalized === undefined) return undefined;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "updatedAfter must be a valid date" });
|
||||
return undefined;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function parseUpdatedWithin(value: unknown, ctx: z.RefinementCtx): string | undefined {
|
||||
const normalized = parseOptionalString(value, ctx, "updatedWithin");
|
||||
if (normalized === undefined) return undefined;
|
||||
if (!UPDATED_WITHIN_RE.test(normalized)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "updatedWithin must be a duration like 24h, 7d, 4w, or 3m" });
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export const companySearchQuerySchema = z.object({
|
||||
q: z.preprocess(firstQueryValue, z.string().optional().default(""))
|
||||
.transform((value) => value.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)),
|
||||
scope: z.preprocess(firstQueryValue, z.enum(COMPANY_SEARCH_SCOPES).catch("all")).optional().default("all"),
|
||||
q: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => (parseOptionalString(value, ctx, "q") ?? "").slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)),
|
||||
scope: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => {
|
||||
const normalized = parseOptionalString(value, ctx, "scope") ?? "all";
|
||||
if (!(COMPANY_SEARCH_SCOPES as readonly string[]).includes(normalized)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "scope must be a supported search scope" });
|
||||
return "all";
|
||||
}
|
||||
return normalized as (typeof COMPANY_SEARCH_SCOPES)[number];
|
||||
}),
|
||||
limit: z.unknown()
|
||||
.optional()
|
||||
.transform((value) => clampInteger(value, COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)),
|
||||
.transform((value, ctx) => parseIntegerQuery(value, ctx, "limit", COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)),
|
||||
offset: z.unknown()
|
||||
.optional()
|
||||
.transform((value) => clampInteger(value, 0, 0, COMPANY_SEARCH_MAX_OFFSET)),
|
||||
.transform((value, ctx) => parseIntegerQuery(value, ctx, "offset", 0, 0, COMPANY_SEARCH_MAX_OFFSET)),
|
||||
status: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseEnumList(value, ctx, "status", ISSUE_STATUSES)),
|
||||
priority: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseEnumList(value, ctx, "priority", ISSUE_PRIORITIES)),
|
||||
assigneeAgentId: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseAssigneeAgentId(value, ctx)),
|
||||
assigneeUserId: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseOptionalString(value, ctx, "assigneeUserId")),
|
||||
projectId: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseOptionalUuid(value, ctx, "projectId")),
|
||||
labelId: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseOptionalUuid(value, ctx, "labelId")),
|
||||
updatedWithin: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseUpdatedWithin(value, ctx)),
|
||||
updatedAfter: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => parseUpdatedAfter(value, ctx)),
|
||||
sort: z.unknown()
|
||||
.optional()
|
||||
.transform((value, ctx) => {
|
||||
const normalized = parseOptionalString(value, ctx, "sort") ?? "relevance";
|
||||
if (!(COMPANY_SEARCH_SORTS as readonly string[]).includes(normalized)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "sort must be relevance, updated, created, or priority" });
|
||||
return "relevance";
|
||||
}
|
||||
return normalized as (typeof COMPANY_SEARCH_SORTS)[number];
|
||||
}),
|
||||
});
|
||||
|
||||
export type CompanySearchQuery = z.infer<typeof companySearchQuerySchema>;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,18 @@ function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse
|
|||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
results: [],
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 },
|
||||
sort: query.sort,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,4 +62,60 @@ describe("company search route rate limiting", () => {
|
|||
});
|
||||
expect(limited.headers["retry-after"]).toBe("60");
|
||||
});
|
||||
it("resolves assigneeUserId=me for board actors before invoking search", async () => {
|
||||
const search = vi.fn(async (_companyId: string, query: CompanySearchQuery) => createSearchResponse(query));
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 10,
|
||||
windowMs: 60_000,
|
||||
now: () => 1_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
await request(app).get("/api/companies/company-1/search?q=wizard&assigneeUserId=me").expect(200);
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(search.mock.calls[0]?.[1].assigneeUserId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("rejects invalid filter and sort params before invoking search", async () => {
|
||||
const search = vi.fn(async (_companyId: string, query: CompanySearchQuery) => createSearchResponse(query));
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 10,
|
||||
windowMs: 60_000,
|
||||
now: () => 1_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
await request(app).get("/api/companies/company-1/search?q=wizard&sort=nope").expect(400);
|
||||
await request(app).get("/api/companies/company-1/search?q=wizard&assigneeAgentId=nope").expect(400);
|
||||
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import {
|
|||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueLabels,
|
||||
issues,
|
||||
labels,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
|
|
@ -32,23 +34,36 @@ if (!embeddedPostgresSupport.supported) {
|
|||
}
|
||||
|
||||
describe("company search query validation", () => {
|
||||
it("clamps query length, limit, and offset without rejecting the request", () => {
|
||||
it("truncates long text queries but rejects invalid filters, sort, and pagination", () => {
|
||||
const parsed = companySearchQuerySchema.parse({
|
||||
q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50),
|
||||
limit: "500",
|
||||
offset: "9000",
|
||||
scope: "not-a-scope",
|
||||
limit: "50",
|
||||
offset: "200",
|
||||
scope: "all",
|
||||
status: "todo,blocked",
|
||||
priority: ["critical", "low"],
|
||||
sort: "priority",
|
||||
updatedWithin: "7d",
|
||||
});
|
||||
|
||||
expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH);
|
||||
expect(parsed.limit).toBe(50);
|
||||
expect(parsed.offset).toBe(200);
|
||||
expect(parsed.scope).toBe("all");
|
||||
expect(parsed.status).toEqual(["todo", "blocked"]);
|
||||
expect(parsed.priority).toEqual(["critical", "low"]);
|
||||
expect(parsed.sort).toBe("priority");
|
||||
expect(parsed.updatedWithin).toBe("7d");
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", limit: "500" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", offset: "9000" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", scope: "not-a-scope" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", status: "not-a-status" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", priority: "urgent" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", sort: "oldest" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", updatedWithin: "forever" })).toThrow();
|
||||
expect(() => companySearchQuerySchema.parse({ q: "needle", projectId: "not-a-uuid" })).toThrow();
|
||||
});
|
||||
|
||||
it("includes offset in the internal per-branch fetch window", () => {
|
||||
const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" });
|
||||
const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "9000" });
|
||||
const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "200" });
|
||||
|
||||
expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51);
|
||||
expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT);
|
||||
|
|
@ -71,7 +86,9 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueLabels);
|
||||
await db.delete(issues);
|
||||
await db.delete(labels);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
|
|
@ -134,6 +151,18 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
return id;
|
||||
}
|
||||
|
||||
async function createLabel(companyId: string, values: Partial<typeof labels.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(labels).values({
|
||||
id,
|
||||
companyId,
|
||||
name: values.name ?? "Search label",
|
||||
color: values.color ?? "blue",
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it("ranks exact issue identifiers before weaker title matches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const exactId = await createIssue(companyId, {
|
||||
|
|
@ -151,6 +180,30 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
expect(result.results[0]?.matchedFields).toContain("identifier");
|
||||
});
|
||||
|
||||
it("ranks phrase and all-token issue matches before partial scattered-token matches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
const partialTokenId = await createIssue(companyId, {
|
||||
identifier: "TST-50",
|
||||
title: "Alpha-only deployment",
|
||||
updatedAt: new Date(base + 3_000),
|
||||
});
|
||||
const allTokenId = await createIssue(companyId, {
|
||||
identifier: "TST-51",
|
||||
title: "Alpha rollout beta",
|
||||
updatedAt: new Date(base + 2_000),
|
||||
});
|
||||
const phraseId = await createIssue(companyId, {
|
||||
identifier: "TST-52",
|
||||
title: "Alpha beta deployment",
|
||||
updatedAt: new Date(base + 1_000),
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" }));
|
||||
|
||||
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId, partialTokenId]);
|
||||
});
|
||||
|
||||
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueId = await createIssue(companyId, {
|
||||
|
|
@ -158,7 +211,9 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
title: "Checkout semantics",
|
||||
description: "Atomic ownership is enforced here.",
|
||||
});
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
body: "The ranking snippet should explain why this thread matched.",
|
||||
|
|
@ -169,7 +224,10 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
|
||||
expect(match).toBeTruthy();
|
||||
expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"]));
|
||||
expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true);
|
||||
expect(match?.href).toContain(`#comment-${commentId}`);
|
||||
expect(match?.snippets.some((snippet) => snippet.field === "comment" && /snippet/i.test(snippet.text))).toBe(true);
|
||||
expect(match?.snippets.find((snippet) => snippet.field === "comment")?.highlights.length).toBeGreaterThan(0);
|
||||
expect(result.countsByType.comment).toBe(1);
|
||||
});
|
||||
|
||||
it("searches issue documents and returns document metadata for snippets", async () => {
|
||||
|
|
@ -200,6 +258,9 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
expect(result.results[0]?.matchedFields).toContain("document");
|
||||
expect(result.results[0]?.href).toContain("#document-plan");
|
||||
expect(result.results[0]?.snippet).toMatch(/parser/i);
|
||||
expect(result.results[0]?.snippets[0]).toMatchObject({ field: "document", label: "Hermes Parser Plan" });
|
||||
expect(result.results[0]?.snippets[0]?.highlights.length).toBeGreaterThan(0);
|
||||
expect(result.countsByType.document).toBe(1);
|
||||
});
|
||||
|
||||
it("searches artifact projections through the artifacts scope", async () => {
|
||||
|
|
@ -238,7 +299,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
}),
|
||||
});
|
||||
expect(result.results[0]?.snippet).toMatch(/comet tail/i);
|
||||
expect(result.countsByType).toEqual({ issue: 0, artifact: 1, agent: 0, project: 0 });
|
||||
expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 1, agent: 0, project: 0 });
|
||||
});
|
||||
|
||||
it("does not pass high-offset search fetch windows through to artifact query validation", async () => {
|
||||
|
|
@ -255,6 +316,159 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
expect(result.countsByType.artifact).toBe(0);
|
||||
});
|
||||
|
||||
it("applies issue filters before sorting and pagination", async () => {
|
||||
const companyId = await createCompany();
|
||||
const agentId = await createAgent(companyId, { name: "Needle engineer" });
|
||||
const projectId = await createProject(companyId, { name: "Needle project" });
|
||||
const labelId = await createLabel(companyId, { name: "Needle label" });
|
||||
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
const newestMatch = await createIssue(companyId, {
|
||||
identifier: "TST-30",
|
||||
title: "Needle newest",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
projectId,
|
||||
updatedAt: new Date(base + 3_000),
|
||||
});
|
||||
const olderMatch = await createIssue(companyId, {
|
||||
identifier: "TST-31",
|
||||
title: "Needle older",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
projectId,
|
||||
updatedAt: new Date(base + 2_000),
|
||||
});
|
||||
const statusDecoy = await createIssue(companyId, {
|
||||
identifier: "TST-32",
|
||||
title: "Needle done",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
projectId,
|
||||
updatedAt: new Date(base + 4_000),
|
||||
});
|
||||
await db.insert(issueLabels).values([
|
||||
{ companyId, issueId: newestMatch, labelId },
|
||||
{ companyId, issueId: olderMatch, labelId },
|
||||
{ companyId, issueId: statusDecoy, labelId },
|
||||
]);
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({
|
||||
q: "needle",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
projectId,
|
||||
labelId,
|
||||
updatedAfter: new Date(base + 1_000).toISOString(),
|
||||
sort: "updated",
|
||||
limit: "1",
|
||||
offset: "1",
|
||||
}));
|
||||
|
||||
expect(result.results.map((row) => row.id)).toEqual([olderMatch]);
|
||||
expect(result.countsByType.issue).toBe(2);
|
||||
expect(result.countsByType.agent).toBe(0);
|
||||
expect(result.countsByType.project).toBe(0);
|
||||
expect(result.filterOptionCounts.status.todo).toBe(2);
|
||||
expect(result.filterOptionCounts.status.done).toBe(1);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("returns issue rows for filter-only searches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const agentId = await createAgent(companyId, { name: "Filter owner" });
|
||||
const matchingIssue = await createIssue(companyId, {
|
||||
identifier: "TST-34",
|
||||
title: "Filtered task",
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await createIssue(companyId, {
|
||||
identifier: "TST-35",
|
||||
title: "Filtered decoy",
|
||||
status: "done",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await createAgent(companyId, { name: "Todo" });
|
||||
await createProject(companyId, { name: "Todo" });
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({
|
||||
q: "",
|
||||
status: "todo",
|
||||
assigneeAgentId: agentId,
|
||||
}));
|
||||
|
||||
expect(result.results.map((row) => row.id)).toEqual([matchingIssue]);
|
||||
expect(result.countsByType.issue).toBe(1);
|
||||
expect(result.countsByType.agent).toBe(0);
|
||||
expect(result.countsByType.project).toBe(0);
|
||||
expect(result.results[0]?.snippets).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns zero-result loosen data and suppresses agent/project rows while issue filters are active", async () => {
|
||||
const companyId = await createCompany();
|
||||
await createAgent(companyId, { name: "Needle agent", capabilities: "Needle capabilities" });
|
||||
await createProject(companyId, { name: "Needle project", description: "Needle roadmap" });
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", status: "todo" }));
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.countsByType.agent).toBe(0);
|
||||
expect(result.countsByType.project).toBe(0);
|
||||
expect(result.zeroResults).toMatchObject({
|
||||
unfilteredTotal: 2,
|
||||
loosenSuggestions: [
|
||||
{ filter: "status", values: ["todo"], resultCount: 2, additionalCount: 2 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak hidden issue-backed artifacts", async () => {
|
||||
const companyId = await createCompany();
|
||||
const agentId = await createAgent(companyId, { name: "Artifact Writer" });
|
||||
const visibleIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-33",
|
||||
title: "Visible artifact holder",
|
||||
});
|
||||
const hiddenIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-34",
|
||||
title: "Hidden artifact holder",
|
||||
hiddenAt: new Date(),
|
||||
});
|
||||
const visibleDocumentId = randomUUID();
|
||||
const hiddenDocumentId = randomUUID();
|
||||
await db.insert(documents).values([
|
||||
{
|
||||
id: visibleDocumentId,
|
||||
companyId,
|
||||
title: "Visible Artifact",
|
||||
latestBody: "Searchable artifact body",
|
||||
format: "markdown",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: hiddenDocumentId,
|
||||
companyId,
|
||||
title: "Hidden Artifact",
|
||||
latestBody: "Searchable artifact body",
|
||||
format: "markdown",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueDocuments).values([
|
||||
{ companyId, issueId: visibleIssueId, documentId: visibleDocumentId, key: "visible" },
|
||||
{ companyId, issueId: hiddenIssueId, documentId: hiddenDocumentId, key: "hidden" },
|
||||
]);
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "artifact", scope: "artifacts" }));
|
||||
|
||||
expect(result.results.map((row) => row.artifact?.issueId)).toEqual([visibleIssueId]);
|
||||
expect(result.countsByType.artifact).toBe(1);
|
||||
});
|
||||
|
||||
it("excludes hidden issues and other companies' data", async () => {
|
||||
const companyId = await createCompany("Visible Co");
|
||||
const otherCompanyId = await createCompany("Other Co");
|
||||
|
|
@ -445,7 +659,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
|||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" }));
|
||||
|
||||
expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]);
|
||||
expect(result.countsByType).toEqual({ issue: 0, artifact: 0, agent: 3, project: 3 });
|
||||
expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 0, agent: 3, project: 3 });
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4432,7 +4432,21 @@ export function issueRoutes(
|
|||
res.status(403).json({ error: "Company search is outside this actor's authorization boundary" });
|
||||
return;
|
||||
}
|
||||
const query = companySearchQuerySchema.parse(req.query);
|
||||
const parsedQuery = companySearchQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
res.status(400).json({
|
||||
error: parsedQuery.error.issues[0]?.message ?? "Invalid search query",
|
||||
});
|
||||
return;
|
||||
}
|
||||
let query = parsedQuery.data;
|
||||
if (query.assigneeUserId === "me") {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
query = { ...query, assigneeUserId: req.actor.userId };
|
||||
}
|
||||
const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId));
|
||||
res.setHeader("X-RateLimit-Limit", String(rateLimit.limit));
|
||||
res.setHeader("X-RateLimit-Remaining", String(rateLimit.remaining));
|
||||
|
|
|
|||
|
|
@ -315,7 +315,11 @@ function buildArtifactGroups(input: {
|
|||
|
||||
export function companyArtifactsService(db: Db, storage?: StorageService) {
|
||||
return {
|
||||
list: async (companyId: string, rawQuery: Partial<CompanyArtifactsQuery> = {}): Promise<CompanyArtifactsResponse> => {
|
||||
list: async (
|
||||
companyId: string,
|
||||
rawQuery: Partial<CompanyArtifactsQuery> = {},
|
||||
options: { issueConditions?: SQL[] } = {},
|
||||
): Promise<CompanyArtifactsResponse> => {
|
||||
const query = companyArtifactsQuerySchema.parse(rawQuery);
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
const groupBy = query.groupBy === "none" ? null : query.groupBy;
|
||||
|
|
@ -329,6 +333,11 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
|
|||
const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1);
|
||||
const sourceFetchLimit = groupBy ? GROUPED_ARTIFACT_FETCH_LIMIT : fetchLimit;
|
||||
const q = query.q ? `%${escapeLikePattern(query.q)}%` : null;
|
||||
const issueConditions: SQL[] = [
|
||||
isNull(issues.hiddenAt),
|
||||
isNull(issues.harnessKind),
|
||||
...(options.issueConditions ?? []),
|
||||
];
|
||||
const artifacts: CompanyArtifact[] = [];
|
||||
const workProductAttachmentIds = new Set<string>();
|
||||
|
||||
|
|
@ -341,6 +350,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
|
|||
eq(documents.companyId, companyId),
|
||||
or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!,
|
||||
notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]),
|
||||
...issueConditions,
|
||||
];
|
||||
const documentCursor = groupBy ? undefined : cursorCondition(sql<Date>`${documents.updatedAt}`, documentArtifactId, cursor);
|
||||
if (documentCursor) documentConditions.push(documentCursor);
|
||||
|
|
@ -442,6 +452,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
|
|||
eq(issueWorkProducts.companyId, companyId),
|
||||
eq(issueWorkProducts.type, "artifact"),
|
||||
eq(issueWorkProducts.provider, "paperclip"),
|
||||
...issueConditions,
|
||||
];
|
||||
const workProductConditions: SQL[] = [...workProductBaseConditions];
|
||||
const workProductCursor = groupBy
|
||||
|
|
@ -578,6 +589,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
|
|||
eq(issueAttachments.companyId, companyId),
|
||||
isNull(issueAttachments.issueCommentId),
|
||||
isNotNull(assets.createdByAgentId),
|
||||
...issueConditions,
|
||||
];
|
||||
const attachmentCursor = groupBy
|
||||
? undefined
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
|||
import type { CompanySearchResponse, CompanySearchScope } from "@paperclipai/shared";
|
||||
import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueStatus } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CompanySearchParams {
|
||||
|
|
@ -6,6 +6,19 @@ export interface CompanySearchParams {
|
|||
scope?: CompanySearchScope;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: IssueStatus[];
|
||||
priority?: IssuePriority[];
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string;
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
updatedWithin?: string;
|
||||
updatedAfter?: string;
|
||||
sort?: CompanySearchSort;
|
||||
}
|
||||
|
||||
function appendMulti(search: URLSearchParams, key: string, values: readonly string[] | undefined) {
|
||||
for (const value of values ?? []) search.append(key, value);
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
|
|
@ -15,6 +28,15 @@ export const searchApi = {
|
|||
if (params.scope) search.set("scope", params.scope);
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
appendMulti(search, "status", params.status);
|
||||
appendMulti(search, "priority", params.priority);
|
||||
if (params.assigneeAgentId !== undefined) search.set("assigneeAgentId", params.assigneeAgentId ?? "null");
|
||||
if (params.assigneeUserId !== undefined) search.set("assigneeUserId", params.assigneeUserId);
|
||||
if (params.projectId !== undefined) search.set("projectId", params.projectId);
|
||||
if (params.labelId !== undefined) search.set("labelId", params.labelId);
|
||||
if (params.updatedWithin !== undefined) search.set("updatedWithin", params.updatedWithin);
|
||||
if (params.updatedAfter !== undefined) search.set("updatedAfter", params.updatedAfter);
|
||||
if (params.sort !== undefined) search.set("sort", params.sort);
|
||||
const qs = search.toString();
|
||||
return api.get<CompanySearchResponse>(
|
||||
`/companies/${companyId}/search${qs ? `?${qs}` : ""}`,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const sidebarState = vi.hoisted(() => ({
|
|||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
|
|
@ -46,6 +47,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
|
|
@ -87,6 +92,10 @@ vi.mock("../api/instanceSettings", () => ({
|
|||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
|
@ -190,19 +199,23 @@ describe("CommandPalette", () => {
|
|||
dialogState.openNewAgent.mockReset();
|
||||
sidebarState.setSidebarOpen.mockReset();
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAgentsApi.list.mockReset();
|
||||
mockProjectsApi.list.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
navigateState.navigate.mockReset();
|
||||
locationState.location.pathname = "/";
|
||||
locationState.location.search = "";
|
||||
locationState.location.hash = "";
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableExperimentalFileViewer: false,
|
||||
});
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" }, session: { userId: "user-1" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -312,7 +325,7 @@ describe("CommandPalette", () => {
|
|||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth%20flake");
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth+flake");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -416,4 +429,73 @@ describe("CommandPalette", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders quick-filter chips and inserts them into the palette query", async () => {
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const chips = Array.from(container.querySelectorAll('button[data-testid="command-filter-chip"]'));
|
||||
expect(chips.map((chip) => chip.textContent)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("assignee:me"),
|
||||
expect.stringContaining("is:open"),
|
||||
expect.stringContaining("updated:>7d"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
container.querySelector('button[data-testid="command-filter-chip"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement;
|
||||
await waitForAssertion(() => {
|
||||
expect(input.value).toBe("assignee:me");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("parses operators for lightweight issue search but keeps filters for command-enter handoff", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
nativeSetter.call(input, "auth status:blocked updated:>7d");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||
q: "auth",
|
||||
limit: 10,
|
||||
includeRoutineExecutions: true,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth&status=blocked&updatedWithin=7d");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
|
|||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -34,12 +35,17 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { agentUrl, projectUrl } from "../lib/utils";
|
||||
import {
|
||||
SEARCH_OPERATOR_QUICK_FILTERS,
|
||||
buildSearchPathFromQuery,
|
||||
parseSearchQuery,
|
||||
type SearchQueryParserContext,
|
||||
} from "../lib/search-query-parser";
|
||||
|
||||
const SEARCH_ALL_VALUE = "__paperclip-search-all__";
|
||||
|
||||
export function buildFullSearchPath(query: string) {
|
||||
const trimmed = query.trim();
|
||||
return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`;
|
||||
export function buildFullSearchPath(query: string, context: SearchQueryParserContext = {}) {
|
||||
return buildSearchPathFromQuery(query, context);
|
||||
}
|
||||
|
||||
const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/;
|
||||
|
|
@ -114,18 +120,6 @@ export function CommandPalette() {
|
|||
if (!open) setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const { data: issues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
|
|
@ -142,13 +136,47 @@ export function CommandPalette() {
|
|||
[allProjects],
|
||||
);
|
||||
|
||||
const { data: labels = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const parserContext = useMemo<SearchQueryParserContext>(() => ({
|
||||
currentUserId,
|
||||
agents,
|
||||
projects,
|
||||
labels,
|
||||
}), [agents, currentUserId, labels, projects]);
|
||||
const parsedQuery = useMemo(() => parseSearchQuery(query, parserContext), [parserContext, query]);
|
||||
const quickSearchQuery = parsedQuery.query.trim();
|
||||
|
||||
const { data: issues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, quickSearchQuery, undefined, 10),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: quickSearchQuery, limit: 10, includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId && open && quickSearchQuery.length > 0,
|
||||
});
|
||||
|
||||
function go(path: string) {
|
||||
setOpen(false);
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
function goFullSearch() {
|
||||
go(buildFullSearchPath(searchQuery));
|
||||
go(buildFullSearchPath(searchQuery, parserContext));
|
||||
}
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
|
|
@ -157,16 +185,16 @@ export function CommandPalette() {
|
|||
};
|
||||
|
||||
const visibleIssues = useMemo(
|
||||
() => (searchQuery.length > 0 ? searchedIssues : issues),
|
||||
[issues, searchedIssues, searchQuery],
|
||||
() => (quickSearchQuery.length > 0 ? searchedIssues : issues),
|
||||
[issues, searchedIssues, quickSearchQuery],
|
||||
);
|
||||
|
||||
// Client-side typeahead ranking over the already-loaded projects. cmdk ranks
|
||||
// items by their `value` (which defaults to the rendered name) and would bury
|
||||
// or drop description-only matches, so we rank in JS and force-match below.
|
||||
const matchedProjects = useMemo(() => {
|
||||
if (searchQuery.length === 0) return [];
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (quickSearchQuery.length === 0) return [];
|
||||
const q = quickSearchQuery.toLowerCase();
|
||||
return projects
|
||||
.map((project) => ({
|
||||
project,
|
||||
|
|
@ -180,7 +208,7 @@ export function CommandPalette() {
|
|||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_MATCHED_PROJECTS)
|
||||
.map((entry) => entry.project);
|
||||
}, [projects, searchQuery]);
|
||||
}, [projects, quickSearchQuery]);
|
||||
|
||||
const showSearchAll = searchQuery.length > 0;
|
||||
const showPromotedProjects = showSearchAll && matchedProjects.length > 0;
|
||||
|
|
@ -198,6 +226,11 @@ export function CommandPalette() {
|
|||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
goFullSearch();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && showEmptyHint) {
|
||||
event.preventDefault();
|
||||
goFullSearch();
|
||||
|
|
@ -239,6 +272,22 @@ export function CommandPalette() {
|
|||
|
||||
{showSearchAll ? <CommandSeparator /> : null}
|
||||
|
||||
<CommandGroup heading="Quick filters">
|
||||
{SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => (
|
||||
<CommandItem
|
||||
key={chip}
|
||||
value={`quick-filter ${chip}`}
|
||||
onSelect={() => setQuery((current) => current.trim() ? `${current.trim()} ${chip}` : chip)}
|
||||
data-testid="command-filter-chip"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span className="font-mono text-xs">{chip}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
{showPromotedProjects && (
|
||||
<>
|
||||
<CommandGroup heading="Projects">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import { useMemo } from "react";
|
||||
import { User, UserX } from "lucide-react";
|
||||
import {
|
||||
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_STATUSES,
|
||||
type CompanySearchFilterOptionCounts,
|
||||
type CompanySearchSort,
|
||||
type IssueStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { SearchFilterMenu, type FilterMenuOption } from "./SearchFilterMenu";
|
||||
import { SearchSortMenu } from "./SearchSortMenu";
|
||||
import {
|
||||
applyAssigneeToken,
|
||||
assigneeToken,
|
||||
updatedWithinLabel,
|
||||
type SearchFilters,
|
||||
} from "@/lib/search-filters";
|
||||
|
||||
export interface SearchFilterAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface SearchFilterProject {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface SearchFilterLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SearchFilterDataProps {
|
||||
counts?: CompanySearchFilterOptionCounts;
|
||||
agents: SearchFilterAgent[];
|
||||
projects: SearchFilterProject[];
|
||||
labels: SearchFilterLabel[];
|
||||
currentUserId: string | null;
|
||||
}
|
||||
|
||||
// Non-terminal statuses — the single-click "Open items" preset from wireframe screen 2.
|
||||
const OPEN_STATUS_PRESET: IssueStatus[] = ISSUE_STATUSES.filter(
|
||||
(status) => status !== "done" && status !== "cancelled",
|
||||
);
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function count(record: Record<string, number> | undefined, key: string): number | undefined {
|
||||
return record?.[key];
|
||||
}
|
||||
|
||||
export interface SearchFilterOptionGroups {
|
||||
status: FilterMenuOption[];
|
||||
priority: FilterMenuOption[];
|
||||
assignee: FilterMenuOption[];
|
||||
project: FilterMenuOption[];
|
||||
label: FilterMenuOption[];
|
||||
updated: FilterMenuOption[];
|
||||
}
|
||||
|
||||
/** Build option lists (with filter-aware counts) shared by the desktop bar and mobile sheet. */
|
||||
export function buildSearchFilterOptions({
|
||||
counts,
|
||||
agents,
|
||||
projects,
|
||||
labels,
|
||||
currentUserId,
|
||||
}: SearchFilterDataProps): SearchFilterOptionGroups {
|
||||
const status: FilterMenuOption[] = ISSUE_STATUSES.map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
icon: <StatusIcon status={value} />,
|
||||
count: count(counts?.status as Record<string, number> | undefined, value),
|
||||
}));
|
||||
|
||||
const priority: FilterMenuOption[] = ISSUE_PRIORITIES.map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
icon: <PriorityIcon priority={value} />,
|
||||
count: count(counts?.priority as Record<string, number> | undefined, value),
|
||||
}));
|
||||
|
||||
const assignee: FilterMenuOption[] = [];
|
||||
if (currentUserId) {
|
||||
assignee.push({
|
||||
value: "me",
|
||||
label: "Me",
|
||||
icon: <User className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
count: count(counts?.assigneeUserId, currentUserId),
|
||||
searchText: "me mine",
|
||||
});
|
||||
}
|
||||
assignee.push({
|
||||
value: "none",
|
||||
label: "Unassigned",
|
||||
icon: <UserX className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
searchText: "unassigned none nobody",
|
||||
});
|
||||
for (const agent of agents) {
|
||||
assignee.push({
|
||||
value: `agent:${agent.id}`,
|
||||
label: agent.name,
|
||||
count: count(counts?.assigneeAgentId, agent.id),
|
||||
searchText: agent.name,
|
||||
});
|
||||
}
|
||||
|
||||
const project: FilterMenuOption[] = projects.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
count: count(counts?.projectId, item.id),
|
||||
searchText: item.name,
|
||||
}));
|
||||
|
||||
const label: FilterMenuOption[] = labels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
swatch: item.color,
|
||||
count: count(counts?.labelId, item.id),
|
||||
searchText: item.name,
|
||||
}));
|
||||
|
||||
const updated: FilterMenuOption[] = COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS.map((value) => ({
|
||||
value,
|
||||
label: updatedWithinLabel(value),
|
||||
count: count(counts?.updatedWithin as Record<string, number> | undefined, value),
|
||||
}));
|
||||
|
||||
return { status, priority, assignee, project, label, updated };
|
||||
}
|
||||
|
||||
export function SearchFilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
data,
|
||||
}: {
|
||||
filters: SearchFilters;
|
||||
onChange: (next: SearchFilters) => void;
|
||||
sort: CompanySearchSort;
|
||||
onSortChange: (next: CompanySearchSort) => void;
|
||||
data: SearchFilterDataProps;
|
||||
}) {
|
||||
const options = useMemo(() => buildSearchFilterOptions(data), [data]);
|
||||
|
||||
function toggleMulti(dimension: "status" | "priority", value: string) {
|
||||
const current = (filters[dimension] ?? []) as string[];
|
||||
const next = current.includes(value)
|
||||
? current.filter((entry) => entry !== value)
|
||||
: [...current, value];
|
||||
onChange({ ...filters, [dimension]: next });
|
||||
}
|
||||
|
||||
const selectedAssignee = assigneeToken(filters, data.currentUserId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-filter-bar">
|
||||
<SearchFilterMenu
|
||||
label="Status"
|
||||
multi
|
||||
options={options.status}
|
||||
selected={filters.status ?? []}
|
||||
onToggle={(value) => toggleMulti("status", value)}
|
||||
onClear={() => onChange({ ...filters, status: [] })}
|
||||
presets={[{ label: "Open items", values: OPEN_STATUS_PRESET }]}
|
||||
/>
|
||||
<SearchFilterMenu
|
||||
label="Assignee"
|
||||
options={options.assignee}
|
||||
selected={selectedAssignee ? [selectedAssignee] : []}
|
||||
onSelect={(value) => onChange(applyAssigneeToken(filters, value, data.currentUserId))}
|
||||
searchable
|
||||
searchPlaceholder="Search assignees…"
|
||||
emptyMessage="No assignees"
|
||||
/>
|
||||
<SearchFilterMenu
|
||||
label="Project"
|
||||
options={options.project}
|
||||
selected={filters.projectId ? [filters.projectId] : []}
|
||||
onSelect={(value) => onChange({ ...filters, projectId: value })}
|
||||
searchable
|
||||
searchPlaceholder="Search projects…"
|
||||
emptyMessage="No projects"
|
||||
/>
|
||||
<SearchFilterMenu
|
||||
label="Label"
|
||||
options={options.label}
|
||||
selected={filters.labelId ? [filters.labelId] : []}
|
||||
onSelect={(value) => onChange({ ...filters, labelId: value })}
|
||||
searchable
|
||||
searchPlaceholder="Search labels…"
|
||||
emptyMessage="No labels"
|
||||
/>
|
||||
<SearchFilterMenu
|
||||
label="Priority"
|
||||
multi
|
||||
options={options.priority}
|
||||
selected={filters.priority ?? []}
|
||||
onToggle={(value) => toggleMulti("priority", value)}
|
||||
onClear={() => onChange({ ...filters, priority: [] })}
|
||||
/>
|
||||
<SearchFilterMenu
|
||||
label="Updated"
|
||||
options={options.updated}
|
||||
selected={filters.updatedWithin ? [filters.updatedWithin] : []}
|
||||
onSelect={(value) => onChange({ ...filters, updatedWithin: value })}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<SearchSortMenu value={sort} onChange={onSortChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildFilterChips, type FilterChipLookups, type SearchFilters } from "@/lib/search-filters";
|
||||
|
||||
export function SearchFilterChips({
|
||||
filters,
|
||||
lookups,
|
||||
onChange,
|
||||
onClearAll,
|
||||
}: {
|
||||
filters: SearchFilters;
|
||||
lookups: FilterChipLookups;
|
||||
onChange: (next: SearchFilters) => void;
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
const chips = buildFilterChips(filters, lookups);
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-filter-chips">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
|
||||
<span className="truncate">{chip.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-background/60"
|
||||
onClick={() => onChange(chip.remove(filters))}
|
||||
aria-label={`Remove filter ${chip.label}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FilterMenuOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
icon?: ReactNode;
|
||||
swatch?: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
export interface FilterMenuPreset {
|
||||
label: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
label: string;
|
||||
options: FilterMenuOption[];
|
||||
/** Values currently selected. */
|
||||
selected: string[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
triggerClassName?: string;
|
||||
contentClassName?: string;
|
||||
align?: "start" | "end";
|
||||
}
|
||||
|
||||
interface MultiProps extends BaseProps {
|
||||
multi: true;
|
||||
onToggle: (value: string) => void;
|
||||
onClear: () => void;
|
||||
presets?: FilterMenuPreset[];
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
multi?: false;
|
||||
onSelect: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export type SearchFilterMenuProps = MultiProps | SingleProps;
|
||||
|
||||
function summarizeTrigger(label: string, selected: string[], options: FilterMenuOption[]): string {
|
||||
if (selected.length === 0) return label;
|
||||
if (selected.length === 1) {
|
||||
const only = options.find((option) => option.value === selected[0]);
|
||||
return only ? `${label}: ${only.label}` : label;
|
||||
}
|
||||
return `${label}: ${selected.length}`;
|
||||
}
|
||||
|
||||
export function SearchFilterMenu(props: SearchFilterMenuProps) {
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search…",
|
||||
emptyMessage = "No options",
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
align = "start",
|
||||
} = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const visibleOptions = useMemo(() => {
|
||||
if (!normalized) return options;
|
||||
return options.filter((option) =>
|
||||
`${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalized),
|
||||
);
|
||||
}, [normalized, options]);
|
||||
|
||||
const active = selected.length > 0;
|
||||
|
||||
function handleOptionClick(value: string) {
|
||||
if (props.multi) {
|
||||
props.onToggle(value);
|
||||
return;
|
||||
}
|
||||
// Single-select: clicking the selected value clears it, otherwise selects.
|
||||
props.onSelect(selected.includes(value) ? undefined : value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1 text-xs font-normal",
|
||||
active && "border-primary/60 text-foreground",
|
||||
triggerClassName,
|
||||
)}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
<span className="truncate">{summarizeTrigger(label, selected, options)}</span>
|
||||
{active ? (
|
||||
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-(length:--text-nano) font-semibold tabular-nums text-primary-foreground">
|
||||
{selected.length}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} className={cn("w-64 p-0", contentClassName)}>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{props.multi && active ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => props.onClear()}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{props.multi && props.presets && props.presets.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 px-3 pb-2">
|
||||
{props.presets.map((preset) => {
|
||||
const isActive =
|
||||
preset.values.length === selected.length &&
|
||||
preset.values.every((value) => selected.includes(value));
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-(length:--text-micro) transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
// Replace selection with the preset (toggle off when already exact).
|
||||
for (const value of options.map((option) => option.value)) {
|
||||
const wantSelected = !isActive && preset.values.includes(value);
|
||||
const currentlySelected = selected.includes(value);
|
||||
if (wantSelected !== currentlySelected) props.onToggle(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchable ? (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-72 overflow-y-auto overscroll-contain border-t border-border py-1">
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
visibleOptions.map((option) => {
|
||||
const isSelected = selected.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
{props.multi ? (
|
||||
<Checkbox checked={isSelected} tabIndex={-1} className="pointer-events-none" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center">
|
||||
{isSelected ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
|
||||
</span>
|
||||
)}
|
||||
{option.icon ? <span className="flex h-4 w-4 items-center justify-center">{option.icon}</span> : null}
|
||||
{option.swatch ? (
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.swatch }}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{option.label}</span>
|
||||
{typeof option.count === "number" ? (
|
||||
<span className="ml-1 text-xs tabular-nums text-muted-foreground">{option.count}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { COMPANY_SEARCH_SORTS, type CompanySearchSort } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
applyAssigneeToken,
|
||||
assigneeToken,
|
||||
countActiveFilters,
|
||||
SORT_LABELS,
|
||||
type SearchFilters,
|
||||
} from "@/lib/search-filters";
|
||||
import { buildSearchFilterOptions, type SearchFilterDataProps } from "./SearchFilterBar";
|
||||
import type { FilterMenuOption } from "./SearchFilterMenu";
|
||||
|
||||
function ChipToggleGroup({
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
options: FilterMenuOption[];
|
||||
selected: string[];
|
||||
onToggle: (value: string) => void;
|
||||
}) {
|
||||
if (options.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">{title}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((option) => {
|
||||
const isActive = selected.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onToggle(option.value)}
|
||||
>
|
||||
{option.swatch ? (
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: option.swatch }} aria-hidden />
|
||||
) : null}
|
||||
<span>{option.label}</span>
|
||||
{typeof option.count === "number" ? (
|
||||
<span className={cn("tabular-nums", isActive ? "opacity-80" : "text-muted-foreground/70")}>
|
||||
{option.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchFilterSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
onApply,
|
||||
onDraftChange,
|
||||
previewTotal,
|
||||
data,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: SearchFilters;
|
||||
onApply: (next: SearchFilters) => void;
|
||||
/** Fires whenever the in-sheet draft changes so the parent can preview the count. */
|
||||
onDraftChange: (draft: SearchFilters) => void;
|
||||
/** Total result count for the current draft, previewed before applying. */
|
||||
previewTotal: number | null;
|
||||
data: SearchFilterDataProps;
|
||||
sort: CompanySearchSort;
|
||||
onSortChange: (next: CompanySearchSort) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<SearchFilters>(filters);
|
||||
const options = buildSearchFilterOptions(data);
|
||||
|
||||
// Re-seed the draft from committed filters each time the sheet opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(filters);
|
||||
onDraftChange(filters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
function update(next: SearchFilters) {
|
||||
setDraft(next);
|
||||
onDraftChange(next);
|
||||
}
|
||||
|
||||
function toggleMulti(dimension: "status" | "priority", value: string) {
|
||||
const current = (draft[dimension] ?? []) as string[];
|
||||
const next = current.includes(value)
|
||||
? current.filter((entry) => entry !== value)
|
||||
: [...current, value];
|
||||
update({ ...draft, [dimension]: next });
|
||||
}
|
||||
|
||||
function toggleAssignee(token: string) {
|
||||
const current = assigneeToken(draft, data.currentUserId);
|
||||
update(applyAssigneeToken(draft, current === token ? undefined : token, data.currentUserId));
|
||||
}
|
||||
|
||||
function toggleSingle(dimension: "projectId" | "labelId" | "updatedWithin", value: string) {
|
||||
const current = draft[dimension];
|
||||
update({ ...draft, [dimension]: current === value ? undefined : value });
|
||||
}
|
||||
|
||||
const activeCount = countActiveFilters(draft);
|
||||
const selectedAssignee = assigneeToken(draft, data.currentUserId);
|
||||
const applyLabel =
|
||||
previewTotal === null
|
||||
? "Show results"
|
||||
: `Show ${previewTotal} ${previewTotal === 1 ? "result" : "results"}`;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="max-h-(--sz-85vh) gap-0 rounded-t-xl p-0" data-testid="search-filter-sheet">
|
||||
<SheetHeader className="flex-row items-center justify-between border-b border-border">
|
||||
<SheetTitle className="text-base">Filters</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("text-xs text-muted-foreground hover:text-foreground", activeCount === 0 && "invisible")}
|
||||
onClick={() => update({})}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<ChipToggleGroup
|
||||
title="Status"
|
||||
options={options.status}
|
||||
selected={draft.status ?? []}
|
||||
onToggle={(value) => toggleMulti("status", value)}
|
||||
/>
|
||||
<ChipToggleGroup
|
||||
title="Priority"
|
||||
options={options.priority}
|
||||
selected={draft.priority ?? []}
|
||||
onToggle={(value) => toggleMulti("priority", value)}
|
||||
/>
|
||||
<ChipToggleGroup
|
||||
title="Assignee"
|
||||
options={options.assignee}
|
||||
selected={selectedAssignee ? [selectedAssignee] : []}
|
||||
onToggle={toggleAssignee}
|
||||
/>
|
||||
<ChipToggleGroup
|
||||
title="Project"
|
||||
options={options.project}
|
||||
selected={draft.projectId ? [draft.projectId] : []}
|
||||
onToggle={(value) => toggleSingle("projectId", value)}
|
||||
/>
|
||||
<ChipToggleGroup
|
||||
title="Label"
|
||||
options={options.label}
|
||||
selected={draft.labelId ? [draft.labelId] : []}
|
||||
onToggle={(value) => toggleSingle("labelId", value)}
|
||||
/>
|
||||
<ChipToggleGroup
|
||||
title="Updated"
|
||||
options={options.updated}
|
||||
selected={draft.updatedWithin ? [draft.updatedWithin] : []}
|
||||
onToggle={(value) => toggleSingle("updatedWithin", value)}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">Sort by</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COMPANY_SEARCH_SORTS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs transition-colors",
|
||||
value === sort
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onSortChange(value)}
|
||||
>
|
||||
{SORT_LABELS[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
onApply(draft);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{applyLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** The compact "Filters · n" trigger button shown on mobile. */
|
||||
export function SearchFilterSheetTrigger({
|
||||
activeCount,
|
||||
onClick,
|
||||
}: {
|
||||
activeCount: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs font-normal" onClick={onClick}>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{activeCount > 0 ? (
|
||||
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-(length:--text-nano) font-semibold tabular-nums text-primary-foreground">
|
||||
{activeCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -256,7 +256,11 @@ function SnippetLine({ text, highlights, field, fallbackLabel, multiline = false
|
|||
className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/60", multiline && "mt-0.5")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{label}: </span>
|
||||
<span
|
||||
className="shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<HighlightedText
|
||||
text={text}
|
||||
highlights={highlights}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { ArrowUpDown, Check } from "lucide-react";
|
||||
import { COMPANY_SEARCH_SORTS, type CompanySearchSort } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SORT_LABELS } from "@/lib/search-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SearchSortMenu({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CompanySearchSort;
|
||||
onChange: (next: CompanySearchSort) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs font-normal" aria-label="Sort results">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
<span className="hidden sm:inline text-muted-foreground">Sort:</span>
|
||||
<span>{SORT_LABELS[value]}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{COMPANY_SEARCH_SORTS.map((sort) => (
|
||||
<DropdownMenuItem key={sort} onSelect={() => onChange(sort)} className="gap-2 text-sm">
|
||||
<Check className={cn("h-3.5 w-3.5", sort === value ? "opacity-100 text-primary" : "opacity-0")} />
|
||||
{SORT_LABELS[sort]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { FilterX, RotateCcw } from "lucide-react";
|
||||
import type { CompanySearchZeroResults } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
clearFilterDimension,
|
||||
countActiveFilters,
|
||||
describeLoosenSuggestion,
|
||||
type FilterChipLookups,
|
||||
type SearchFilters,
|
||||
} from "@/lib/search-filters";
|
||||
|
||||
export function ZeroResultsRecovery({
|
||||
query,
|
||||
filters,
|
||||
zeroResults,
|
||||
lookups,
|
||||
onChange,
|
||||
onClearAll,
|
||||
}: {
|
||||
query: string;
|
||||
filters: SearchFilters;
|
||||
zeroResults: CompanySearchZeroResults;
|
||||
lookups: FilterChipLookups;
|
||||
onChange: (next: SearchFilters) => void;
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
const activeCount = countActiveFilters(filters);
|
||||
const { unfilteredTotal } = zeroResults;
|
||||
// Rank suggestions by how many results each one recovers (highest impact first).
|
||||
const suggestions = [...zeroResults.loosenSuggestions].sort(
|
||||
(a, b) => b.additionalCount - a.additionalCount,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 px-4 py-12 text-center"
|
||||
data-testid="search-zero-results-recovery"
|
||||
>
|
||||
<FilterX className="h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<div className="text-base font-semibold">No results with these filters</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{unfilteredTotal === 1 ? "1 result matches" : `${unfilteredTotal} results match`}
|
||||
{query ? <> “{query}”</> : null}, but your{" "}
|
||||
{activeCount === 1 ? "active filter hides" : `${activeCount} active filters hide`} all of them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Loosen a filter
|
||||
</div>
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`${suggestion.filter}:${suggestion.values.join(",")}`}
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2 text-left text-sm hover:border-foreground/30 hover:bg-accent/40"
|
||||
onClick={() => onChange(clearFilterDimension(filters, suggestion.filter))}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
Remove{" "}
|
||||
<span className="font-medium">
|
||||
{describeLoosenSuggestion(suggestion.filter, suggestion.values, lookups)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
+{suggestion.additionalCount} {suggestion.additionalCount === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button onClick={onClearAll} variant="default" size="sm">
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" />
|
||||
Clear all filters
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import {
|
||||
COMPANY_SEARCH_SORTS,
|
||||
type CompanySearchSort,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ParsedSearchQuery } from "./search-query-parser";
|
||||
|
||||
/**
|
||||
* The issue-scoped filter model for /search. This is the SAME shape the query
|
||||
* parser (search-query-parser.ts) and the URL round-trip already use — we build
|
||||
* the P2 filter-bar UI directly on top of it rather than inventing a second
|
||||
* scheme. `sort` lives alongside the filters but is tracked separately (it is not
|
||||
* part of the parser's filter set).
|
||||
*/
|
||||
export type SearchFilters = ParsedSearchQuery["filters"];
|
||||
|
||||
export const SORT_LABELS: Record<CompanySearchSort, string> = {
|
||||
relevance: "Relevance",
|
||||
updated: "Recently updated",
|
||||
created: "Newest created",
|
||||
priority: "Priority",
|
||||
};
|
||||
|
||||
export const UPDATED_WITHIN_LABELS: Record<string, string> = {
|
||||
"24h": "Last 24 hours",
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
"90d": "Last 90 days",
|
||||
};
|
||||
|
||||
export function updatedWithinLabel(value: string): string {
|
||||
return UPDATED_WITHIN_LABELS[value] ?? `Updated ≤ ${value}`;
|
||||
}
|
||||
|
||||
const SORT_SET = new Set<string>(COMPANY_SEARCH_SORTS);
|
||||
|
||||
export function parseSearchSort(params: URLSearchParams): CompanySearchSort {
|
||||
const raw = params.get("sort");
|
||||
return raw && SORT_SET.has(raw) ? (raw as CompanySearchSort) : "relevance";
|
||||
}
|
||||
|
||||
/** Count active filter *dimensions* (assignee counts once regardless of shape). */
|
||||
export function countActiveFilters(filters: SearchFilters): number {
|
||||
let count = 0;
|
||||
if (filters.status?.length) count += 1;
|
||||
if (filters.priority?.length) count += 1;
|
||||
if (filters.assigneeAgentId !== undefined || filters.assigneeUserId) count += 1;
|
||||
if (filters.projectId) count += 1;
|
||||
if (filters.labelId) count += 1;
|
||||
if (filters.updatedWithin || filters.updatedAfter) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignee: the UI treats assignee as a single choice, but the wire model splits
|
||||
// it across assigneeAgentId (string | null) and assigneeUserId (string). These
|
||||
// helpers translate between a single opaque token and that split representation.
|
||||
// "me" → assigneeUserId = currentUserId
|
||||
// "none" → assigneeAgentId = null (unassigned)
|
||||
// "agent:<id>" → assigneeAgentId
|
||||
// "user:<id>" → assigneeUserId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function assigneeToken(filters: SearchFilters, currentUserId: string | null): string | undefined {
|
||||
if (filters.assigneeAgentId === null) return "none";
|
||||
if (typeof filters.assigneeAgentId === "string") return `agent:${filters.assigneeAgentId}`;
|
||||
if (filters.assigneeUserId) {
|
||||
return filters.assigneeUserId === currentUserId ? "me" : `user:${filters.assigneeUserId}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyAssigneeToken(
|
||||
filters: SearchFilters,
|
||||
token: string | undefined,
|
||||
currentUserId: string | null,
|
||||
): SearchFilters {
|
||||
const next: SearchFilters = { ...filters };
|
||||
delete next.assigneeAgentId;
|
||||
delete next.assigneeUserId;
|
||||
if (!token) return next;
|
||||
if (token === "none") {
|
||||
next.assigneeAgentId = null;
|
||||
} else if (token === "me") {
|
||||
if (currentUserId) next.assigneeUserId = currentUserId;
|
||||
} else if (token.startsWith("agent:")) {
|
||||
next.assigneeAgentId = token.slice("agent:".length);
|
||||
} else if (token.startsWith("user:")) {
|
||||
next.assigneeUserId = token.slice("user:".length);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface FilterChipLookups {
|
||||
agentName: (id: string) => string | undefined;
|
||||
userName: (id: string) => string | undefined;
|
||||
projectName: (id: string) => string | undefined;
|
||||
labelName: (id: string) => string | undefined;
|
||||
currentUserId: string | null;
|
||||
}
|
||||
|
||||
export interface FilterChip {
|
||||
id: string;
|
||||
label: string;
|
||||
remove: (filters: SearchFilters) => SearchFilters;
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function assigneeChipLabel(filters: SearchFilters, lookups: FilterChipLookups): string {
|
||||
if (filters.assigneeAgentId === null) return "Unassigned";
|
||||
if (typeof filters.assigneeAgentId === "string") {
|
||||
return lookups.agentName(filters.assigneeAgentId) ?? "Agent";
|
||||
}
|
||||
if (filters.assigneeUserId) {
|
||||
if (filters.assigneeUserId === lookups.currentUserId) return "Me";
|
||||
return lookups.userName(filters.assigneeUserId) ?? "User";
|
||||
}
|
||||
return "Assignee";
|
||||
}
|
||||
|
||||
/** Removable chip descriptors for the active-filter row. */
|
||||
export function buildFilterChips(filters: SearchFilters, lookups: FilterChipLookups): FilterChip[] {
|
||||
const chips: FilterChip[] = [];
|
||||
for (const status of filters.status ?? []) {
|
||||
chips.push({
|
||||
id: `status:${status}`,
|
||||
label: `Status: ${humanize(status)}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
const remaining = (current.status ?? []).filter((value) => value !== status);
|
||||
if (remaining.length > 0) next.status = remaining;
|
||||
else delete next.status;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const priority of filters.priority ?? []) {
|
||||
chips.push({
|
||||
id: `priority:${priority}`,
|
||||
label: `Priority: ${humanize(priority)}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
const remaining = (current.priority ?? []).filter((value) => value !== priority);
|
||||
if (remaining.length > 0) next.priority = remaining;
|
||||
else delete next.priority;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filters.assigneeAgentId !== undefined || filters.assigneeUserId) {
|
||||
chips.push({
|
||||
id: "assignee",
|
||||
label: `Assignee: ${assigneeChipLabel(filters, lookups)}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
delete next.assigneeAgentId;
|
||||
delete next.assigneeUserId;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filters.projectId) {
|
||||
chips.push({
|
||||
id: "project",
|
||||
label: `Project: ${lookups.projectName(filters.projectId) ?? "Project"}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
delete next.projectId;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filters.labelId) {
|
||||
chips.push({
|
||||
id: "label",
|
||||
label: `Label: ${lookups.labelName(filters.labelId) ?? "Label"}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
delete next.labelId;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filters.updatedWithin) {
|
||||
chips.push({
|
||||
id: "updated",
|
||||
label: `Updated: ${updatedWithinLabel(filters.updatedWithin)}`,
|
||||
remove: (current) => {
|
||||
const next = { ...current };
|
||||
delete next.updatedWithin;
|
||||
delete next.updatedAfter;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** Human label for a backend zero-results loosen suggestion. */
|
||||
export function describeLoosenSuggestion(filterKey: string, values: string[], lookups: FilterChipLookups): string {
|
||||
switch (filterKey) {
|
||||
case "status":
|
||||
return `Status: ${values.map(humanize).join(", ")}`;
|
||||
case "priority":
|
||||
return `Priority: ${values.map(humanize).join(", ")}`;
|
||||
case "assigneeAgentId":
|
||||
return `Assignee: ${values.map((id) => lookups.agentName(id) ?? "Agent").join(", ")}`;
|
||||
case "assigneeUserId":
|
||||
return `Assignee: ${values.map((id) => (id === lookups.currentUserId ? "Me" : lookups.userName(id) ?? "User")).join(", ")}`;
|
||||
case "projectId":
|
||||
return `Project: ${values.map((id) => lookups.projectName(id) ?? "Project").join(", ")}`;
|
||||
case "labelId":
|
||||
return `Label: ${values.map((id) => lookups.labelName(id) ?? "Label").join(", ")}`;
|
||||
case "updatedWithin":
|
||||
case "updatedAfter":
|
||||
return "Updated window";
|
||||
default:
|
||||
return humanize(filterKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the filter dimension a loosen suggestion refers to. */
|
||||
export function clearFilterDimension(filters: SearchFilters, filterKey: string): SearchFilters {
|
||||
const next: SearchFilters = { ...filters };
|
||||
switch (filterKey) {
|
||||
case "status":
|
||||
delete next.status;
|
||||
break;
|
||||
case "priority":
|
||||
delete next.priority;
|
||||
break;
|
||||
case "assigneeAgentId":
|
||||
case "assigneeUserId":
|
||||
delete next.assigneeAgentId;
|
||||
delete next.assigneeUserId;
|
||||
break;
|
||||
case "projectId":
|
||||
delete next.projectId;
|
||||
break;
|
||||
case "labelId":
|
||||
delete next.labelId;
|
||||
break;
|
||||
case "updatedWithin":
|
||||
case "updatedAfter":
|
||||
delete next.updatedWithin;
|
||||
delete next.updatedAfter;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applySearchOperatorSuggestion,
|
||||
buildSearchPathFromQuery,
|
||||
parseSearchQuery,
|
||||
readSearchFiltersFromParams,
|
||||
searchOperatorSuggestions,
|
||||
} from "./search-query-parser";
|
||||
|
||||
const context = {
|
||||
currentUserId: "user-1",
|
||||
agents: [
|
||||
{ id: "agent-1", name: "Codex Coder", urlKey: "codex-coder" },
|
||||
{ id: "agent-2", name: "QA" },
|
||||
],
|
||||
projects: [
|
||||
{ id: "11111111-1111-4111-8111-111111111111", name: "Paperclip App", urlKey: "paperclip-app" },
|
||||
],
|
||||
labels: [
|
||||
{ id: "22222222-2222-4222-8222-222222222222", name: "bug" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("parseSearchQuery", () => {
|
||||
it("parses status operators", () => {
|
||||
expect(parseSearchQuery("status:todo auth", context)).toMatchObject({
|
||||
query: "auth",
|
||||
filters: { status: ["todo"] },
|
||||
pills: [{ key: "status", value: "todo", label: "status:todo" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses assignee:me to the current user", () => {
|
||||
expect(parseSearchQuery("assignee:me", context).filters).toEqual({
|
||||
assigneeUserId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses assignee names including quoted multi-word names", () => {
|
||||
expect(parseSearchQuery("assignee:\"Codex Coder\" crash", context)).toMatchObject({
|
||||
query: "crash",
|
||||
filters: { assigneeAgentId: "agent-1" },
|
||||
pills: [{ key: "assignee", value: "Codex Coder", label: "assignee:Codex Coder" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses project names", () => {
|
||||
expect(parseSearchQuery("project:paperclip-app", context).filters).toEqual({
|
||||
projectId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses label names", () => {
|
||||
expect(parseSearchQuery("label:bug", context).filters).toEqual({
|
||||
labelId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses priority operators", () => {
|
||||
expect(parseSearchQuery("priority:high", context).filters).toEqual({
|
||||
priority: ["high"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses updated:>7d as updatedWithin", () => {
|
||||
expect(parseSearchQuery("updated:>7d", context).filters).toEqual({
|
||||
updatedWithin: "7d",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses is:open quick filters", () => {
|
||||
expect(parseSearchQuery("is:open", context).filters).toEqual({
|
||||
status: ["backlog", "todo", "in_progress", "in_review", "blocked"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves quoted phrases in free text", () => {
|
||||
expect(parseSearchQuery("\"auth flake\" status:blocked", context)).toMatchObject({
|
||||
query: "\"auth flake\"",
|
||||
filters: { status: ["blocked"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses mixed free text and multiple operators", () => {
|
||||
expect(parseSearchQuery("auth status:in_progress priority:critical project:paperclip-app", context)).toMatchObject({
|
||||
query: "auth",
|
||||
filters: {
|
||||
status: ["in_progress"],
|
||||
priority: ["critical"],
|
||||
projectId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls unknown operators through to plain text", () => {
|
||||
expect(parseSearchQuery("owner:me auth", context)).toMatchObject({
|
||||
query: "owner:me auth",
|
||||
filters: {},
|
||||
pills: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls malformed values through to plain text", () => {
|
||||
expect(parseSearchQuery("status:notreal updated:>soon priority:urgent", context)).toMatchObject({
|
||||
query: "status:notreal updated:>soon priority:urgent",
|
||||
filters: {},
|
||||
pills: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search query URLs", () => {
|
||||
it("builds /search paths with parsed filters", () => {
|
||||
expect(buildSearchPathFromQuery("auth status:todo updated:>7d", context)).toBe(
|
||||
"/search?q=auth&status=todo&updatedWithin=7d",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads filter params back from URLSearchParams", () => {
|
||||
const filters = readSearchFiltersFromParams(
|
||||
new URLSearchParams("q=auth&status=todo&status=blocked&priority=high&updatedWithin=7d"),
|
||||
);
|
||||
expect(filters).toEqual({
|
||||
status: ["todo", "blocked"],
|
||||
priority: ["high"],
|
||||
updatedWithin: "7d",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search operator suggestions", () => {
|
||||
it("suggests syntax for the current partial token", () => {
|
||||
expect(searchOperatorSuggestions("auth sta").map((suggestion) => suggestion.token)).toEqual([
|
||||
"status:todo",
|
||||
"status:blocked",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces only the current token when applying a suggestion", () => {
|
||||
expect(applySearchOperatorSuggestion("auth sta", "status:todo")).toBe("auth status:todo");
|
||||
expect(applySearchOperatorSuggestion("", "assignee:me")).toBe("assignee:me");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
import {
|
||||
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_STATUSES,
|
||||
isUuidLike,
|
||||
normalizeAgentUrlKey,
|
||||
type IssuePriority,
|
||||
type IssueStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import type { CompanySearchParams } from "@/api/search";
|
||||
|
||||
const SEARCH_FILTER_PARAM_KEYS = [
|
||||
"status",
|
||||
"priority",
|
||||
"assigneeAgentId",
|
||||
"assigneeUserId",
|
||||
"projectId",
|
||||
"labelId",
|
||||
"updatedWithin",
|
||||
"updatedAfter",
|
||||
] as const;
|
||||
|
||||
const OPEN_STATUSES: IssueStatus[] = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
||||
const CLOSED_STATUSES: IssueStatus[] = ["done", "cancelled"];
|
||||
|
||||
export type SearchOperatorKey = "status" | "assignee" | "project" | "label" | "priority" | "updated" | "is";
|
||||
|
||||
export interface SearchOperatorPill {
|
||||
key: SearchOperatorKey;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SearchOperatorSuggestion {
|
||||
token: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SEARCH_OPERATOR_QUICK_FILTERS = ["assignee:me", "is:open", "updated:>7d"] as const;
|
||||
|
||||
export const SEARCH_OPERATOR_SUGGESTIONS: SearchOperatorSuggestion[] = [
|
||||
{ token: "status:todo", label: "Open todo tasks", description: "Filter by task status" },
|
||||
{ token: "status:blocked", label: "Blocked tasks", description: "Find blocked work" },
|
||||
{ token: "assignee:me", label: "Assigned to me", description: "Use your current board user" },
|
||||
{ token: "project:\"Paperclip App\"", label: "Project name", description: "Quote multi-word project names" },
|
||||
{ token: "label:bug", label: "Label", description: "Filter by issue label" },
|
||||
{ token: "priority:high", label: "High priority", description: "Filter by priority" },
|
||||
{ token: "updated:>7d", label: "Recently updated", description: "Updated in the last 7 days" },
|
||||
];
|
||||
|
||||
export interface SearchQueryParserContext {
|
||||
currentAgentId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
agents?: readonly { id: string; name: string; urlKey?: string | null }[];
|
||||
projects?: readonly { id: string; name: string; urlKey?: string | null }[];
|
||||
labels?: readonly { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface ParsedSearchQuery {
|
||||
query: string;
|
||||
filters: Pick<
|
||||
CompanySearchParams,
|
||||
| "status"
|
||||
| "priority"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
| "projectId"
|
||||
| "labelId"
|
||||
| "updatedWithin"
|
||||
| "updatedAfter"
|
||||
>;
|
||||
pills: SearchOperatorPill[];
|
||||
}
|
||||
|
||||
interface QueryToken {
|
||||
raw: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function stripValueQuotes(value: string) {
|
||||
if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenizeQuery(input: string): QueryToken[] {
|
||||
const tokens: QueryToken[] = [];
|
||||
let index = 0;
|
||||
while (index < input.length) {
|
||||
while (/\s/.test(input[index] ?? "")) index += 1;
|
||||
if (index >= input.length) break;
|
||||
|
||||
const start = index;
|
||||
if (input[index] === "\"") {
|
||||
index += 1;
|
||||
while (index < input.length && input[index] !== "\"") index += 1;
|
||||
if (input[index] === "\"") index += 1;
|
||||
const raw = input.slice(start, index);
|
||||
tokens.push({ raw, value: raw });
|
||||
continue;
|
||||
}
|
||||
|
||||
while (index < input.length && !/\s/.test(input[index] ?? "")) {
|
||||
if (input[index] === ":" && input[index + 1] === "\"") {
|
||||
index += 2;
|
||||
while (index < input.length && input[index] !== "\"") index += 1;
|
||||
if (input[index] === "\"") index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const raw = input.slice(start, index);
|
||||
tokens.push({ raw, value: raw });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function currentTokenBounds(input: string): { start: number; end: number; token: string } {
|
||||
let end = input.length;
|
||||
while (end > 0 && /\s/.test(input[end - 1] ?? "")) end -= 1;
|
||||
let start = end;
|
||||
while (start > 0 && !/\s/.test(input[start - 1] ?? "")) start -= 1;
|
||||
return { start, end, token: input.slice(start, end) };
|
||||
}
|
||||
|
||||
export function searchOperatorSuggestions(input: string, limit = 5): SearchOperatorSuggestion[] {
|
||||
const { token } = currentTokenBounds(input);
|
||||
const normalized = token.toLowerCase();
|
||||
const candidates = normalized.length > 0
|
||||
? SEARCH_OPERATOR_SUGGESTIONS.filter((suggestion) => suggestion.token.toLowerCase().startsWith(normalized))
|
||||
: SEARCH_OPERATOR_SUGGESTIONS;
|
||||
return candidates.slice(0, limit);
|
||||
}
|
||||
|
||||
export function applySearchOperatorSuggestion(input: string, token: string): string {
|
||||
const { start, end } = currentTokenBounds(input);
|
||||
const prefix = input.slice(0, start).trimEnd();
|
||||
const suffix = input.slice(end).trimStart();
|
||||
return [prefix, token, suffix].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function normalizedLookup(value: string) {
|
||||
return normalizeAgentUrlKey(value) ?? value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function findByNameOrId<T extends { id: string; name: string; urlKey?: string | null }>(
|
||||
entries: readonly T[] | undefined,
|
||||
value: string,
|
||||
): T | null {
|
||||
const normalized = normalizedLookup(value);
|
||||
return entries?.find((entry) => {
|
||||
if (entry.id === value) return true;
|
||||
if (normalizedLookup(entry.name) === normalized) return true;
|
||||
return entry.urlKey ? normalizedLookup(entry.urlKey) === normalized : false;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function addUnique<T extends string>(values: T[] | undefined, value: T): T[] {
|
||||
return values?.includes(value) ? values : [...(values ?? []), value];
|
||||
}
|
||||
|
||||
function appendText(parts: string[], raw: string) {
|
||||
if (raw.trim().length > 0) parts.push(raw);
|
||||
}
|
||||
|
||||
function parseStatus(value: string): IssueStatus | null {
|
||||
return (ISSUE_STATUSES as readonly string[]).includes(value) ? value as IssueStatus : null;
|
||||
}
|
||||
|
||||
function parsePriority(value: string): IssuePriority | null {
|
||||
return (ISSUE_PRIORITIES as readonly string[]).includes(value) ? value as IssuePriority : null;
|
||||
}
|
||||
|
||||
function parseUpdatedWithin(value: string): string | null {
|
||||
const normalized = value.startsWith(">") ? value.slice(1) : value;
|
||||
if (!/^[1-9]\d{0,2}(h|d|w|m)$/.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function operatorLabel(key: SearchOperatorKey, value: string) {
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
|
||||
export function parseSearchQuery(input: string, context: SearchQueryParserContext = {}): ParsedSearchQuery {
|
||||
const textParts: string[] = [];
|
||||
const filters: ParsedSearchQuery["filters"] = {};
|
||||
const pills: SearchOperatorPill[] = [];
|
||||
|
||||
for (const token of tokenizeQuery(input)) {
|
||||
const match = /^([a-zA-Z]+):(.*)$/s.exec(token.value);
|
||||
if (!match) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1]!.toLowerCase();
|
||||
const rawValue = match[2]!;
|
||||
const value = stripValueQuotes(rawValue).trim();
|
||||
if (!value) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "status") {
|
||||
const status = parseStatus(value);
|
||||
if (!status) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
filters.status = addUnique(filters.status, status);
|
||||
pills.push({ key: "status", value: status, label: operatorLabel("status", status) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "priority") {
|
||||
const priority = parsePriority(value);
|
||||
if (!priority) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
filters.priority = addUnique(filters.priority, priority);
|
||||
pills.push({ key: "priority", value: priority, label: operatorLabel("priority", priority) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "assignee") {
|
||||
if (value.toLowerCase() === "me") {
|
||||
if (context.currentAgentId) {
|
||||
filters.assigneeAgentId = context.currentAgentId;
|
||||
pills.push({ key: "assignee", value: "me", label: "assignee:me" });
|
||||
continue;
|
||||
}
|
||||
if (context.currentUserId) {
|
||||
filters.assigneeUserId = context.currentUserId;
|
||||
pills.push({ key: "assignee", value: "me", label: "assignee:me" });
|
||||
continue;
|
||||
}
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
const agent = findByNameOrId(context.agents, value);
|
||||
if (!agent) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
filters.assigneeAgentId = agent.id;
|
||||
pills.push({ key: "assignee", value: agent.name, label: operatorLabel("assignee", agent.name) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "project") {
|
||||
const project = findByNameOrId(context.projects, value);
|
||||
if (!project) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
filters.projectId = project.id;
|
||||
pills.push({ key: "project", value: project.name, label: operatorLabel("project", project.name) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "label") {
|
||||
const label = findByNameOrId(context.labels, value);
|
||||
if (label) {
|
||||
filters.labelId = label.id;
|
||||
pills.push({ key: "label", value: label.name, label: operatorLabel("label", label.name) });
|
||||
continue;
|
||||
}
|
||||
if (isUuidLike(value)) {
|
||||
filters.labelId = value;
|
||||
pills.push({ key: "label", value, label: operatorLabel("label", value.slice(0, 8)) });
|
||||
continue;
|
||||
}
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "updated") {
|
||||
const updatedWithin = parseUpdatedWithin(value);
|
||||
if (!updatedWithin) {
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
filters.updatedWithin = updatedWithin;
|
||||
pills.push({ key: "updated", value: `>${updatedWithin}`, label: operatorLabel("updated", `>${updatedWithin}`) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "is") {
|
||||
if (value === "open") {
|
||||
filters.status = OPEN_STATUSES;
|
||||
pills.push({ key: "is", value: "open", label: "is:open" });
|
||||
continue;
|
||||
}
|
||||
if (value === "closed") {
|
||||
filters.status = CLOSED_STATUSES;
|
||||
pills.push({ key: "is", value: "closed", label: "is:closed" });
|
||||
continue;
|
||||
}
|
||||
appendText(textParts, token.raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
appendText(textParts, token.raw);
|
||||
}
|
||||
|
||||
return {
|
||||
query: textParts.join(" ").replace(/\s+/g, " ").trim(),
|
||||
filters,
|
||||
pills,
|
||||
};
|
||||
}
|
||||
|
||||
function appendMulti(search: URLSearchParams, key: string, values: readonly string[] | undefined) {
|
||||
for (const value of values ?? []) search.append(key, value);
|
||||
}
|
||||
|
||||
export function clearSearchFilterParams(search: URLSearchParams) {
|
||||
for (const key of SEARCH_FILTER_PARAM_KEYS) search.delete(key);
|
||||
}
|
||||
|
||||
export function applySearchFiltersToParams(search: URLSearchParams, filters: ParsedSearchQuery["filters"]) {
|
||||
clearSearchFilterParams(search);
|
||||
appendMulti(search, "status", filters.status);
|
||||
appendMulti(search, "priority", filters.priority);
|
||||
if (filters.assigneeAgentId !== undefined) search.set("assigneeAgentId", filters.assigneeAgentId ?? "null");
|
||||
if (filters.assigneeUserId !== undefined) search.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters.projectId !== undefined) search.set("projectId", filters.projectId);
|
||||
if (filters.labelId !== undefined) search.set("labelId", filters.labelId);
|
||||
if (filters.updatedWithin !== undefined) search.set("updatedWithin", filters.updatedWithin);
|
||||
if (filters.updatedAfter !== undefined) search.set("updatedAfter", filters.updatedAfter);
|
||||
}
|
||||
|
||||
function validValues<T extends string>(values: string[], allowed: readonly T[]): T[] {
|
||||
return values.filter((value): value is T => (allowed as readonly string[]).includes(value));
|
||||
}
|
||||
|
||||
export function readSearchFiltersFromParams(search: URLSearchParams): ParsedSearchQuery["filters"] {
|
||||
const filters: ParsedSearchQuery["filters"] = {};
|
||||
const statuses = validValues(search.getAll("status").flatMap((value) => value.split(",")), ISSUE_STATUSES);
|
||||
const priorities = validValues(search.getAll("priority").flatMap((value) => value.split(",")), ISSUE_PRIORITIES);
|
||||
const assigneeAgentId = search.get("assigneeAgentId");
|
||||
const assigneeUserId = search.get("assigneeUserId");
|
||||
const projectId = search.get("projectId");
|
||||
const labelId = search.get("labelId");
|
||||
const updatedWithin = search.get("updatedWithin");
|
||||
const updatedAfter = search.get("updatedAfter");
|
||||
|
||||
if (statuses.length > 0) filters.status = statuses;
|
||||
if (priorities.length > 0) filters.priority = priorities;
|
||||
if (assigneeAgentId !== null) filters.assigneeAgentId = assigneeAgentId === "null" ? null : assigneeAgentId;
|
||||
if (assigneeUserId) filters.assigneeUserId = assigneeUserId;
|
||||
if (projectId && isUuidLike(projectId)) filters.projectId = projectId;
|
||||
if (labelId && isUuidLike(labelId)) filters.labelId = labelId;
|
||||
if (updatedWithin && (/^[1-9]\d{0,2}(h|d|w|m)$/.test(updatedWithin) || (COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS as readonly string[]).includes(updatedWithin))) {
|
||||
filters.updatedWithin = updatedWithin;
|
||||
}
|
||||
if (updatedAfter && !Number.isNaN(new Date(updatedAfter).getTime())) filters.updatedAfter = updatedAfter;
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function hasSearchFilters(filters: ParsedSearchQuery["filters"]) {
|
||||
return Boolean(
|
||||
filters.status?.length
|
||||
|| filters.priority?.length
|
||||
|| filters.assigneeAgentId !== undefined
|
||||
|| filters.assigneeUserId
|
||||
|| filters.projectId
|
||||
|| filters.labelId
|
||||
|| filters.updatedWithin
|
||||
|| filters.updatedAfter,
|
||||
);
|
||||
}
|
||||
|
||||
function nameForId<T extends { id: string; name: string }>(entries: readonly T[] | undefined, id: string) {
|
||||
return entries?.find((entry) => entry.id === id)?.name ?? id.slice(0, 8);
|
||||
}
|
||||
|
||||
export function searchFilterPills(
|
||||
filters: ParsedSearchQuery["filters"],
|
||||
context: SearchQueryParserContext = {},
|
||||
): SearchOperatorPill[] {
|
||||
const pills: SearchOperatorPill[] = [];
|
||||
for (const status of filters.status ?? []) {
|
||||
pills.push({ key: "status", value: status, label: operatorLabel("status", status) });
|
||||
}
|
||||
for (const priority of filters.priority ?? []) {
|
||||
pills.push({ key: "priority", value: priority, label: operatorLabel("priority", priority) });
|
||||
}
|
||||
if (filters.assigneeAgentId !== undefined) {
|
||||
const value = filters.assigneeAgentId === null
|
||||
? "unassigned"
|
||||
: nameForId(context.agents, filters.assigneeAgentId);
|
||||
pills.push({ key: "assignee", value, label: operatorLabel("assignee", value) });
|
||||
}
|
||||
if (filters.assigneeUserId) {
|
||||
const value = filters.assigneeUserId === context.currentUserId ? "me" : filters.assigneeUserId.slice(0, 8);
|
||||
pills.push({ key: "assignee", value, label: operatorLabel("assignee", value) });
|
||||
}
|
||||
if (filters.projectId) {
|
||||
const value = nameForId(context.projects, filters.projectId);
|
||||
pills.push({ key: "project", value, label: operatorLabel("project", value) });
|
||||
}
|
||||
if (filters.labelId) {
|
||||
const value = nameForId(context.labels, filters.labelId);
|
||||
pills.push({ key: "label", value, label: operatorLabel("label", value) });
|
||||
}
|
||||
if (filters.updatedWithin) {
|
||||
pills.push({ key: "updated", value: `>${filters.updatedWithin}`, label: operatorLabel("updated", `>${filters.updatedWithin}`) });
|
||||
}
|
||||
if (filters.updatedAfter) {
|
||||
pills.push({ key: "updated", value: filters.updatedAfter, label: operatorLabel("updated", filters.updatedAfter) });
|
||||
}
|
||||
return pills;
|
||||
}
|
||||
|
||||
export function buildSearchPathFromQuery(input: string, context: SearchQueryParserContext = {}) {
|
||||
const parsed = parseSearchQuery(input, context);
|
||||
const search = new URLSearchParams();
|
||||
if (parsed.query.length > 0) search.set("q", parsed.query);
|
||||
applySearchFiltersToParams(search, parsed.filters);
|
||||
const qs = search.toString();
|
||||
return qs ? `/search?${qs}` : "/search";
|
||||
}
|
||||
|
|
@ -34,6 +34,14 @@ 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,
|
||||
}));
|
||||
|
|
@ -62,6 +70,14 @@ 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 {
|
||||
|
|
@ -153,8 +169,12 @@ describe("Search page", () => {
|
|||
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();
|
||||
});
|
||||
|
||||
|
|
@ -169,7 +189,18 @@ describe("Search page", () => {
|
|||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 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: [
|
||||
{
|
||||
|
|
@ -239,7 +270,18 @@ describe("Search page", () => {
|
|||
scope: "artifacts",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 1, agent: 0, project: 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: [
|
||||
{
|
||||
|
|
@ -297,6 +339,138 @@ describe("Search page", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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",
|
||||
|
|
@ -304,7 +478,18 @@ describe("Search page", () => {
|
|||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 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: [],
|
||||
});
|
||||
|
|
@ -348,7 +533,18 @@ describe("Search page", () => {
|
|||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 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: [
|
||||
{
|
||||
|
|
@ -402,7 +598,18 @@ describe("Search page", () => {
|
|||
scope: "comments",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 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: [],
|
||||
});
|
||||
|
|
@ -419,4 +626,365 @@ describe("Search page", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { Search as SearchIcon, AlertTriangle, FileQuestion, Plus, X } from "luci
|
|||
import {
|
||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
COMPANY_SEARCH_SCOPES,
|
||||
type CompanySearchCountType,
|
||||
type CompanySearchResponse,
|
||||
type CompanySearchResult,
|
||||
type CompanySearchScope,
|
||||
type CompanySearchSort,
|
||||
} from "@paperclipai/shared";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -20,12 +22,39 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { searchApi } from "../api/search";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { loadRecentSearches, pushRecentSearch } from "../lib/recent-searches";
|
||||
import { PageTabBar, type PageTabItem } from "../components/PageTabBar";
|
||||
import {
|
||||
applySearchFiltersToParams,
|
||||
applySearchOperatorSuggestion,
|
||||
hasSearchFilters,
|
||||
parseSearchQuery,
|
||||
readSearchFiltersFromParams,
|
||||
searchFilterPills,
|
||||
searchOperatorSuggestions,
|
||||
type ParsedSearchQuery,
|
||||
type SearchQueryParserContext,
|
||||
} from "../lib/search-query-parser";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
import { SearchResultRow } from "../components/search/SearchResultRow";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { SearchFilterBar, type SearchFilterDataProps } from "../components/search/SearchFilterBar";
|
||||
import { SearchFilterChips } from "../components/search/SearchFilterChips";
|
||||
import { SearchFilterSheet, SearchFilterSheetTrigger } from "../components/search/SearchFilterSheet";
|
||||
import { SearchSortMenu } from "../components/search/SearchSortMenu";
|
||||
import { ZeroResultsRecovery } from "../components/search/ZeroResultsRecovery";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
SORT_LABELS,
|
||||
countActiveFilters,
|
||||
parseSearchSort,
|
||||
type FilterChipLookups,
|
||||
} from "../lib/search-filters";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Agent, IssueLabel, Project } from "@paperclipai/shared";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
const IDENTIFIER_PATTERN = /^[A-Z]+-\d+$/;
|
||||
|
|
@ -87,7 +116,31 @@ function describeScope(scope: CompanySearchScope) {
|
|||
return SCOPE_LABELS[scope];
|
||||
}
|
||||
|
||||
export function buildSearchUrl(href: string, query: string, scope: CompanySearchScope): string {
|
||||
function totalMatchCount(counts: Partial<Record<CompanySearchCountType, number>>): number {
|
||||
return (
|
||||
(counts.issue ?? 0)
|
||||
+ (counts.comment ?? 0)
|
||||
+ (counts.document ?? 0)
|
||||
+ (counts.artifact ?? 0)
|
||||
+ (counts.agent ?? 0)
|
||||
+ (counts.project ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSearchFilters(
|
||||
base: ParsedSearchQuery["filters"],
|
||||
override: ParsedSearchQuery["filters"],
|
||||
): ParsedSearchQuery["filters"] {
|
||||
return { ...base, ...override };
|
||||
}
|
||||
|
||||
export function buildSearchUrl(
|
||||
href: string,
|
||||
query: string,
|
||||
scope: CompanySearchScope,
|
||||
filters: ParsedSearchQuery["filters"] = {},
|
||||
sort: CompanySearchSort = "relevance",
|
||||
): string {
|
||||
const url = new URL(href);
|
||||
if (query.length === 0) {
|
||||
url.searchParams.delete("q");
|
||||
|
|
@ -99,6 +152,12 @@ export function buildSearchUrl(href: string, query: string, scope: CompanySearch
|
|||
} else {
|
||||
url.searchParams.set("scope", scope);
|
||||
}
|
||||
applySearchFiltersToParams(url.searchParams, filters);
|
||||
if (sort === "relevance") {
|
||||
url.searchParams.delete("sort");
|
||||
} else {
|
||||
url.searchParams.set("sort", sort);
|
||||
}
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
|
|
@ -118,14 +177,20 @@ export function Search() {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const urlQuery = searchParams.get("q") ?? "";
|
||||
const urlScopeRaw = searchParams.get("scope");
|
||||
const urlScope: CompanySearchScope = isCompanySearchScope(urlScopeRaw) ? urlScopeRaw : "all";
|
||||
const urlSort = useMemo(() => parseSearchSort(searchParams), [searchParams]);
|
||||
|
||||
const [draftQuery, setDraftQuery] = useState(urlQuery);
|
||||
const [committedQuery, setCommittedQuery] = useState(urlQuery);
|
||||
const [scope, setScope] = useState<CompanySearchScope>(urlScope);
|
||||
const [sort, setSort] = useState<CompanySearchSort>(urlSort);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [draftSheetFilters, setDraftSheetFilters] = useState<ParsedSearchQuery["filters"]>({});
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const lastUrlSyncRef = useRef<string>("");
|
||||
const lastIdentifierRedirectRef = useRef<string>("");
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
|
@ -149,13 +214,69 @@ export function Search() {
|
|||
setScope(urlScope);
|
||||
}, [urlScope]);
|
||||
|
||||
// Debounce the draft query into committedQuery and write to URL via replaceState.
|
||||
useEffect(() => {
|
||||
setSort(urlSort);
|
||||
}, [urlSort]);
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: labels = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const parserContext = useMemo<SearchQueryParserContext>(() => ({
|
||||
currentUserId,
|
||||
agents: agents as Agent[],
|
||||
projects: projects as Project[],
|
||||
labels: labels as IssueLabel[],
|
||||
}), [agents, currentUserId, labels, projects]);
|
||||
const parsedUrlFilters = useMemo(() => readSearchFiltersFromParams(searchParams), [searchParams]);
|
||||
const [urlFilters, setUrlFilters] = useState(parsedUrlFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setUrlFilters(parsedUrlFilters);
|
||||
}, [parsedUrlFilters]);
|
||||
const parsedDraftQuery = useMemo(() => parseSearchQuery(draftQuery, parserContext), [draftQuery, parserContext]);
|
||||
const parsedCommittedQuery = useMemo(() => parseSearchQuery(committedQuery, parserContext), [committedQuery, parserContext]);
|
||||
const committedOperatorFilters = parsedCommittedQuery.filters;
|
||||
const draftOperatorFilters = parsedDraftQuery.filters;
|
||||
const activeFilters = useMemo(
|
||||
() => mergeSearchFilters(urlFilters, committedOperatorFilters),
|
||||
[committedOperatorFilters, urlFilters],
|
||||
);
|
||||
const draftFilters = useMemo(
|
||||
() => mergeSearchFilters(urlFilters, draftOperatorFilters),
|
||||
[draftOperatorFilters, urlFilters],
|
||||
);
|
||||
|
||||
// Debounce the draft query into committedQuery and write parsed filters to URL via replaceState.
|
||||
useEffect(() => {
|
||||
if (draftQuery === committedQuery) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
setCommittedQuery(draftQuery);
|
||||
if (typeof window !== "undefined") {
|
||||
const next = buildSearchUrl(window.location.href, draftQuery, scope);
|
||||
// Typed operators live only in the query text and are never folded into
|
||||
// urlFilters, so deleting a token drops its filter from the next request.
|
||||
// The URL still carries the merged view for reload/back-forward persistence.
|
||||
const next = buildSearchUrl(window.location.href, parsedDraftQuery.query, scope, draftFilters, sort);
|
||||
if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}` && next !== lastUrlSyncRef.current) {
|
||||
lastUrlSyncRef.current = next;
|
||||
window.history.replaceState(window.history.state, "", next);
|
||||
|
|
@ -163,60 +284,158 @@ export function Search() {
|
|||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [draftQuery, committedQuery, scope]);
|
||||
}, [draftFilters, draftQuery, committedQuery, parsedDraftQuery.query, scope, sort]);
|
||||
|
||||
const handleScopeChange = useCallback(
|
||||
(next: string) => {
|
||||
if (!isCompanySearchScope(next) || next === scope) return;
|
||||
setScope(next);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = buildSearchUrl(window.location.href, committedQuery, next);
|
||||
const url = buildSearchUrl(window.location.href, parsedCommittedQuery.query, next, activeFilters, sort);
|
||||
window.history.pushState(window.history.state, "", url);
|
||||
}
|
||||
},
|
||||
[committedQuery, scope],
|
||||
[activeFilters, parsedCommittedQuery.query, scope, sort],
|
||||
);
|
||||
|
||||
const trimmedQuery = committedQuery.trim();
|
||||
const queryEnabled = !!selectedCompanyId && trimmedQuery.length > 0;
|
||||
const handleSortChange = useCallback(
|
||||
(next: CompanySearchSort) => {
|
||||
setSort(next);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = buildSearchUrl(window.location.href, parsedCommittedQuery.query, scope, activeFilters, next);
|
||||
window.history.pushState(window.history.state, "", url);
|
||||
}
|
||||
},
|
||||
[activeFilters, parsedCommittedQuery.query, scope],
|
||||
);
|
||||
|
||||
// Filter-bar / chip / sheet changes make the controls authoritative: `next`
|
||||
// already contains any operator-derived values (the controls render the merged
|
||||
// view), so strip the typed tokens from the query to keep the plain text and
|
||||
// prevent a removed filter from resurrecting out of the input.
|
||||
const handleFiltersChange = useCallback(
|
||||
(next: ParsedSearchQuery["filters"]) => {
|
||||
const plain = parsedCommittedQuery.query;
|
||||
setDraftQuery(plain);
|
||||
setCommittedQuery(plain);
|
||||
setUrlFilters(next);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = buildSearchUrl(window.location.href, plain, scope, next, sort);
|
||||
window.history.pushState(window.history.state, "", url);
|
||||
}
|
||||
},
|
||||
[parsedCommittedQuery.query, scope, sort],
|
||||
);
|
||||
|
||||
// "Clear all" drops both URL filters and any typed operator tokens (keeping the
|
||||
// plain text query), so the results snap back to the unfiltered set.
|
||||
const handleClearAllFilters = useCallback(() => {
|
||||
const plain = parsedCommittedQuery.query;
|
||||
setDraftQuery(plain);
|
||||
setCommittedQuery(plain);
|
||||
setUrlFilters({});
|
||||
if (typeof window !== "undefined") {
|
||||
const url = buildSearchUrl(window.location.href, plain, scope, {}, sort);
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
}, [parsedCommittedQuery.query, scope, sort]);
|
||||
|
||||
const trimmedQuery = parsedCommittedQuery.query.trim();
|
||||
const displayQuery = committedQuery.trim();
|
||||
const queryEnabled = !!selectedCompanyId && (trimmedQuery.length > 0 || hasSearchFilters(activeFilters));
|
||||
|
||||
const { data, isFetching, error, refetch } = useQuery<CompanySearchResponse>({
|
||||
queryKey: queryKeys.companySearch.search(
|
||||
selectedCompanyId ?? "__no-company__",
|
||||
trimmedQuery,
|
||||
scope,
|
||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
0,
|
||||
),
|
||||
queryKey: [
|
||||
...queryKeys.companySearch.search(
|
||||
selectedCompanyId ?? "__no-company__",
|
||||
trimmedQuery,
|
||||
scope,
|
||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
0,
|
||||
),
|
||||
activeFilters,
|
||||
sort,
|
||||
] as const,
|
||||
queryFn: () =>
|
||||
searchApi.search(selectedCompanyId!, {
|
||||
q: trimmedQuery,
|
||||
scope,
|
||||
limit: COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
...activeFilters,
|
||||
...(sort !== "relevance" ? { sort } : {}),
|
||||
}),
|
||||
enabled: queryEnabled,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const agentsById = useMemo<ReadonlyMap<string, Pick<Agent, "id" | "name">>>(() => {
|
||||
const map = new Map<string, Pick<Agent, "id" | "name">>();
|
||||
for (const agent of agents ?? []) map.set(agent.id, agent);
|
||||
for (const agent of agents) map.set(agent.id, agent);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const projectsById = useMemo(() => new Map((projects as Project[]).map((p) => [p.id, p])), [projects]);
|
||||
const labelsById = useMemo(() => new Map((labels as IssueLabel[]).map((l) => [l.id, l])), [labels]);
|
||||
|
||||
const filterLookups = useMemo<FilterChipLookups>(
|
||||
() => ({
|
||||
agentName: (id) => agentsById.get(id)?.name,
|
||||
userName: () => undefined,
|
||||
projectName: (id) => projectsById.get(id)?.name,
|
||||
labelName: (id) => labelsById.get(id)?.name,
|
||||
currentUserId,
|
||||
}),
|
||||
[agentsById, projectsById, labelsById, currentUserId],
|
||||
);
|
||||
|
||||
const filterData = useMemo<SearchFilterDataProps>(
|
||||
() => ({
|
||||
counts: data?.filterOptionCounts,
|
||||
agents: agents as Agent[],
|
||||
projects: projects as Project[],
|
||||
labels: labels as IssueLabel[],
|
||||
currentUserId,
|
||||
}),
|
||||
[data?.filterOptionCounts, agents, projects, labels, currentUserId],
|
||||
);
|
||||
|
||||
const filtersActive = hasSearchFilters(activeFilters);
|
||||
const activeFilterCount = countActiveFilters(activeFilters);
|
||||
|
||||
// Preview query for the mobile bottom sheet: run the draft filters so the apply
|
||||
// button can show "Show N results" before the user commits.
|
||||
const { data: previewData } = useQuery<CompanySearchResponse>({
|
||||
queryKey: [
|
||||
...queryKeys.companySearch.search(
|
||||
selectedCompanyId ?? "__no-company__",
|
||||
trimmedQuery,
|
||||
scope,
|
||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
0,
|
||||
),
|
||||
"preview",
|
||||
draftSheetFilters,
|
||||
sort,
|
||||
] as const,
|
||||
queryFn: () =>
|
||||
searchApi.search(selectedCompanyId!, {
|
||||
q: trimmedQuery,
|
||||
scope,
|
||||
limit: COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
...draftSheetFilters,
|
||||
...(sort !== "relevance" ? { sort } : {}),
|
||||
}),
|
||||
enabled: queryEnabled && sheetOpen,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
// Persist recent searches once we have a successful response with a non-empty query.
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) return;
|
||||
if (!data || !trimmedQuery) return;
|
||||
const next = pushRecentSearch(selectedCompanyId, trimmedQuery);
|
||||
if (!data || !displayQuery) return;
|
||||
const next = pushRecentSearch(selectedCompanyId, displayQuery);
|
||||
setRecentSearches(next);
|
||||
}, [data, trimmedQuery, selectedCompanyId]);
|
||||
}, [data, displayQuery, selectedCompanyId]);
|
||||
|
||||
// Identifier shortcut: when q matches PAP-123 and the API returns an exact identifier match, redirect to it.
|
||||
useEffect(() => {
|
||||
|
|
@ -241,7 +460,8 @@ export function Search() {
|
|||
setCommittedQuery("");
|
||||
inputRef.current?.focus();
|
||||
if (typeof window !== "undefined") {
|
||||
const next = buildSearchUrl(window.location.href, "", scope);
|
||||
setUrlFilters({});
|
||||
const next = buildSearchUrl(window.location.href, "", scope, {});
|
||||
window.history.replaceState(window.history.state, "", next);
|
||||
}
|
||||
}, [scope]);
|
||||
|
|
@ -264,8 +484,10 @@ export function Search() {
|
|||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [focusInput]);
|
||||
|
||||
const counts = data?.countsByType ?? { issue: 0, artifact: 0, agent: 0, project: 0 };
|
||||
const counts = data?.countsByType ?? { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 };
|
||||
const totalResults = data?.results.length ?? 0;
|
||||
const allMatchTotal = data ? totalMatchCount(counts) : 0;
|
||||
const previewTotal = previewData ? totalMatchCount(previewData.countsByType) : null;
|
||||
|
||||
const tabItems = useMemo<PageTabItem[]>(() => {
|
||||
function pill(value: number) {
|
||||
|
|
@ -279,26 +501,46 @@ export function Search() {
|
|||
const issuesTotal = counts.issue ?? 0;
|
||||
return COMPANY_SEARCH_SCOPES.map((value) => {
|
||||
let count: number | null = null;
|
||||
if (value === "all") count = (counts.issue ?? 0) + (counts.artifact ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0);
|
||||
else if (value === "issues") count = issuesTotal;
|
||||
if (value === "all") {
|
||||
count = (counts.issue ?? 0)
|
||||
+ (counts.comment ?? 0)
|
||||
+ (counts.document ?? 0)
|
||||
+ (counts.artifact ?? 0)
|
||||
+ (counts.agent ?? 0)
|
||||
+ (counts.project ?? 0);
|
||||
} else if (value === "issues") count = issuesTotal;
|
||||
else if (value === "comments") count = counts.comment ?? 0;
|
||||
else if (value === "documents") count = counts.document ?? 0;
|
||||
else if (value === "artifacts") count = counts.artifact ?? 0;
|
||||
else if (value === "agents") count = counts.agent ?? 0;
|
||||
else if (value === "projects") count = counts.project ?? 0;
|
||||
// Issue-only filters don't constrain agents/projects, so show a dash there
|
||||
// rather than an unfiltered count that would misrepresent the result set.
|
||||
const dashOut = filtersActive && (value === "agents" || value === "projects");
|
||||
return {
|
||||
value,
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
{SCOPE_LABELS[value as CompanySearchScope]}
|
||||
{count !== null ? pill(count) : null}
|
||||
{dashOut ? (
|
||||
<span className="ml-1.5 text-(length:--text-nano) text-muted-foreground">—</span>
|
||||
) : count !== null ? (
|
||||
pill(count)
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
} satisfies PageTabItem;
|
||||
});
|
||||
}, [counts, data]);
|
||||
}, [counts, data, filtersActive]);
|
||||
|
||||
const subgroups = useMemo(() => buildSubgroups(data?.results ?? []), [data?.results]);
|
||||
|
||||
const showInitialState = !trimmedQuery;
|
||||
const operatorPills = useMemo(() => searchFilterPills(draftFilters, parserContext), [draftFilters, parserContext]);
|
||||
const operatorSuggestions = useMemo(
|
||||
() => (inputFocused ? searchOperatorSuggestions(draftQuery, 4) : []),
|
||||
[draftQuery, inputFocused],
|
||||
);
|
||||
const showInitialState = !displayQuery && !hasSearchFilters(activeFilters);
|
||||
const isLoading = queryEnabled && isFetching && !data;
|
||||
const hasResults = !!data && totalResults > 0;
|
||||
const isEmpty = !!data && !isFetching && totalResults === 0;
|
||||
|
|
@ -307,15 +549,30 @@ export function Search() {
|
|||
const apiMessage = data?.results === undefined && data ? null : null;
|
||||
void apiMessage;
|
||||
|
||||
// Zero-results recovery (wireframe screen 4) is only meaningful when active
|
||||
// filters are what emptied the page; the backend signals that via `zeroResults`.
|
||||
const zeroResultsSlot: ReactNode = data?.zeroResults ? (
|
||||
<ZeroResultsRecovery
|
||||
query={displayQuery || trimmedQuery}
|
||||
filters={activeFilters}
|
||||
zeroResults={data.zeroResults}
|
||||
lookups={filterLookups}
|
||||
onChange={handleFiltersChange}
|
||||
onClearAll={handleClearAllFilters}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
function navigateIssuesFallback() {
|
||||
navigate(`/issues?q=${encodeURIComponent(trimmedQuery)}`);
|
||||
const fallbackQuery = trimmedQuery || displayQuery;
|
||||
navigate(fallbackQuery ? `/issues?q=${encodeURIComponent(fallbackQuery)}` : "/issues");
|
||||
}
|
||||
|
||||
function handleRecentClick(value: string) {
|
||||
setDraftQuery(value);
|
||||
setCommittedQuery(value);
|
||||
if (typeof window !== "undefined") {
|
||||
const next = buildSearchUrl(window.location.href, value, scope);
|
||||
setUrlFilters({});
|
||||
const next = buildSearchUrl(window.location.href, value, scope, {});
|
||||
window.history.replaceState(window.history.state, "", next);
|
||||
}
|
||||
}
|
||||
|
|
@ -325,6 +582,8 @@ export function Search() {
|
|||
handleScopeChange("all");
|
||||
}
|
||||
|
||||
const searchDisplayLabel = displayQuery || operatorPills.map((pill) => pill.label).join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col" data-page="search">
|
||||
<div className="border-b border-border px-4 py-3 sm:px-6">
|
||||
|
|
@ -336,6 +595,8 @@ export function Search() {
|
|||
autoFocus
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.currentTarget.value)}
|
||||
onFocus={() => setInputFocused(true)}
|
||||
onBlur={() => setInputFocused(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (draftQuery.length > 0) {
|
||||
|
|
@ -367,6 +628,43 @@ export function Search() {
|
|||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="mt-2 flex min-h-6 flex-wrap items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
|
||||
{operatorPills.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-operator-pills">
|
||||
{operatorPills.map((pill) => (
|
||||
<Badge key={`${pill.key}:${pill.value}`} variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
|
||||
{pill.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{operatorSuggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-operator-suggestions">
|
||||
{operatorSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.token}
|
||||
type="button"
|
||||
aria-label={`Insert operator ${suggestion.token}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setDraftQuery(applySearchOperatorSuggestion(draftQuery, suggestion.token));
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 hover:bg-accent/60"
|
||||
>
|
||||
<span className="font-mono text-(length:--text-micro)">{suggestion.token}</span>
|
||||
<span className="hidden text-(length:--text-micro) sm:inline">{suggestion.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
Try <code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">status:todo</code>,{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">assignee:me</code>,{" "}
|
||||
or <code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">updated:>7d</code>.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={scope} onValueChange={handleScopeChange} className="flex h-full min-h-0 flex-col">
|
||||
|
|
@ -374,6 +672,33 @@ export function Search() {
|
|||
<PageTabBar items={tabItems} value={scope} onValueChange={handleScopeChange} align="start" />
|
||||
</div>
|
||||
|
||||
{!showInitialState ? (
|
||||
<div className="flex flex-col gap-2 border-b border-border px-2 py-2 sm:px-4" data-testid="search-filters">
|
||||
{isMobile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchFilterSheetTrigger activeCount={activeFilterCount} onClick={() => setSheetOpen(true)} />
|
||||
<div className="ml-auto">
|
||||
<SearchSortMenu value={sort} onChange={handleSortChange} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SearchFilterBar
|
||||
filters={activeFilters}
|
||||
onChange={handleFiltersChange}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
data={filterData}
|
||||
/>
|
||||
)}
|
||||
<SearchFilterChips
|
||||
filters={activeFilters}
|
||||
lookups={filterLookups}
|
||||
onChange={handleFiltersChange}
|
||||
onClearAll={handleClearAllFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{COMPANY_SEARCH_SCOPES.map((scopeValue) => (
|
||||
<TabsContent
|
||||
key={scopeValue}
|
||||
|
|
@ -388,16 +713,20 @@ export function Search() {
|
|||
hasError={hasError}
|
||||
apiError={apiError}
|
||||
isEmpty={isEmpty}
|
||||
trimmedQuery={trimmedQuery}
|
||||
trimmedQuery={searchDisplayLabel}
|
||||
scope={scope}
|
||||
showAllScope={showAllScope}
|
||||
navigateIssuesFallback={navigateIssuesFallback}
|
||||
openNewIssue={() => openNewIssue({ title: trimmedQuery })}
|
||||
openNewIssue={() => openNewIssue({ title: searchDisplayLabel })}
|
||||
refetch={() => void refetch()}
|
||||
recentSearches={recentSearches}
|
||||
onRecentClick={handleRecentClick}
|
||||
subgroups={subgroups}
|
||||
totalResults={totalResults}
|
||||
allMatchTotal={allMatchTotal}
|
||||
activeFilterCount={activeFilterCount}
|
||||
sortLabel={SORT_LABELS[sort]}
|
||||
zeroResultsSlot={zeroResultsSlot}
|
||||
isFetching={isFetching && !!data}
|
||||
agentsById={agentsById}
|
||||
/>
|
||||
|
|
@ -405,6 +734,20 @@ export function Search() {
|
|||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{isMobile ? (
|
||||
<SearchFilterSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
filters={activeFilters}
|
||||
onApply={handleFiltersChange}
|
||||
onDraftChange={setDraftSheetFilters}
|
||||
previewTotal={previewTotal}
|
||||
data={filterData}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -426,6 +769,10 @@ interface SearchTabContentProps {
|
|||
onRecentClick: (query: string) => void;
|
||||
subgroups: Array<{ key: SubGroupKey; results: CompanySearchResult[] }>;
|
||||
totalResults: number;
|
||||
allMatchTotal: number;
|
||||
activeFilterCount: number;
|
||||
sortLabel: string;
|
||||
zeroResultsSlot: ReactNode;
|
||||
isFetching: boolean;
|
||||
agentsById: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
}
|
||||
|
|
@ -447,6 +794,10 @@ function SearchTabContent({
|
|||
onRecentClick,
|
||||
subgroups,
|
||||
totalResults,
|
||||
allMatchTotal,
|
||||
activeFilterCount,
|
||||
sortLabel,
|
||||
zeroResultsSlot,
|
||||
isFetching,
|
||||
agentsById,
|
||||
}: SearchTabContentProps) {
|
||||
|
|
@ -545,6 +896,9 @@ function SearchTabContent({
|
|||
}
|
||||
|
||||
if (isEmpty) {
|
||||
// Filters emptied the page → recovery UI (screen 4). Plain zero-results keeps
|
||||
// the tips card below.
|
||||
if (zeroResultsSlot) return zeroResultsSlot;
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col items-center justify-center gap-3 px-4 py-12 text-center">
|
||||
<FileQuestion className="h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
|
|
@ -584,7 +938,15 @@ function SearchTabContent({
|
|||
<div className="flex w-full max-w-(--sz-960px) flex-col px-2 sm:px-4" data-testid="search-results">
|
||||
<div className="flex items-center justify-between py-2 text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
|
||||
<span>
|
||||
{totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance
|
||||
{allMatchTotal > totalResults
|
||||
? `${totalResults} of ${allMatchTotal} results`
|
||||
: totalResults === 1
|
||||
? "1 result"
|
||||
: `${totalResults} results`}
|
||||
{` · sorted by ${sortLabel}`}
|
||||
{activeFilterCount > 0
|
||||
? ` · ${activeFilterCount} ${activeFilterCount === 1 ? "filter" : "filters"} active`
|
||||
: ""}
|
||||
</span>
|
||||
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating…</span> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { CompanySearchResult, CompanySearchResponse } from "@paperclipai/shared";
|
||||
import type {
|
||||
CompanySearchFilterOptionCounts,
|
||||
CompanySearchResult,
|
||||
CompanySearchResponse,
|
||||
CompanySearchZeroResults,
|
||||
} from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { IssueGroupHeader } from "@/components/IssueGroupHeader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageTabBar, type PageTabItem } from "@/components/PageTabBar";
|
||||
import { MatchSourceChip } from "@/components/search/MatchSourceChip";
|
||||
import { SearchResultRow } from "@/components/search/SearchResultRow";
|
||||
import { SearchFilterBar, type SearchFilterDataProps } from "@/components/search/SearchFilterBar";
|
||||
import { SearchFilterChips } from "@/components/search/SearchFilterChips";
|
||||
import { ZeroResultsRecovery } from "@/components/search/ZeroResultsRecovery";
|
||||
import type { FilterChipLookups, SearchFilters } from "@/lib/search-filters";
|
||||
import { SEARCH_OPERATOR_QUICK_FILTERS, searchOperatorSuggestions } from "@/lib/search-query-parser";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
Bot,
|
||||
|
|
@ -189,13 +199,26 @@ const fixtureResponse: CompanySearchResponse = {
|
|||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
sort: "relevance",
|
||||
results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects],
|
||||
countsByType: {
|
||||
issue: fixtureResults.length,
|
||||
comment: 0,
|
||||
document: 0,
|
||||
artifact: 0,
|
||||
agent: fixtureAgents.length,
|
||||
project: fixtureProjects.length,
|
||||
},
|
||||
filterOptionCounts: {
|
||||
status: {},
|
||||
priority: {},
|
||||
assigneeAgentId: {},
|
||||
assigneeUserId: {},
|
||||
projectId: {},
|
||||
labelId: {},
|
||||
updatedWithin: {},
|
||||
},
|
||||
zeroResults: null,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
|
|
@ -388,6 +411,39 @@ function SearchPagePreview({
|
|||
);
|
||||
}
|
||||
|
||||
function SearchOperatorInputPreview() {
|
||||
const suggestions = searchOperatorSuggestions("auth sta", 4);
|
||||
return (
|
||||
<div className="border-t border-border bg-background p-4">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value="auth status:blocked updated:>7d" readOnly className="h-10 pl-9 pr-4 text-sm" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
|
||||
status:blocked
|
||||
</Badge>
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
|
||||
updated:>7d
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{suggestions.map((suggestion) => (
|
||||
<span
|
||||
key={suggestion.token}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5"
|
||||
>
|
||||
<span className="font-mono text-(length:--text-micro)">{suggestion.token}</span>
|
||||
<span className="hidden text-(length:--text-micro) sm:inline">{suggestion.description}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteWithSearchAll({
|
||||
query,
|
||||
emptyResults = false,
|
||||
|
|
@ -424,6 +480,15 @@ function CommandPaletteWithSearchAll({
|
|||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Quick filters">
|
||||
{SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => (
|
||||
<CommandItem key={chip} value={`quick-filter ${chip}`}>
|
||||
<SearchIcon className="mr-2 h-4 w-4" />
|
||||
<span className="font-mono text-xs">{chip}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
|
|
@ -495,6 +560,53 @@ function CommandPaletteWithSearchAll({
|
|||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const searchFilterCounts: CompanySearchFilterOptionCounts = {
|
||||
status: { in_progress: 4, todo: 3, backlog: 2, in_review: 1, blocked: 1, done: 8 },
|
||||
priority: { critical: 1, high: 3, medium: 5, low: 2 },
|
||||
assigneeAgentId: storybookAgents[0]?.id ? { [storybookAgents[0].id]: 4 } : {},
|
||||
assigneeUserId: {},
|
||||
projectId: storybookProjects[0]?.id ? { [storybookProjects[0].id]: 6 } : {},
|
||||
labelId: { "label-infra": 3 },
|
||||
updatedWithin: { "24h": 2, "7d": 5, "30d": 9, "90d": 11 },
|
||||
};
|
||||
|
||||
const searchFilterData: SearchFilterDataProps = {
|
||||
counts: searchFilterCounts,
|
||||
agents: storybookAgents.map((agent) => ({ id: agent.id, name: agent.name })),
|
||||
projects: storybookProjects.map((project) => ({ id: project.id, name: project.name })),
|
||||
labels: [
|
||||
{ id: "label-infra", name: "infra", color: "#a78bfa" },
|
||||
{ id: "label-auth", name: "auth", color: "#34d399" },
|
||||
],
|
||||
currentUserId: "user-1",
|
||||
};
|
||||
|
||||
const activeSearchFilters: SearchFilters = {
|
||||
status: ["in_progress", "todo"],
|
||||
priority: ["high"],
|
||||
projectId: storybookProjects[0]?.id,
|
||||
updatedWithin: "7d",
|
||||
};
|
||||
|
||||
const searchFilterLookups: FilterChipLookups = {
|
||||
agentName: (id) => storybookAgents.find((agent) => agent.id === id)?.name,
|
||||
userName: () => "Me",
|
||||
projectName: (id) => storybookProjects.find((project) => project.id === id)?.name,
|
||||
labelName: (id) => searchFilterData.labels.find((label) => label.id === id)?.name,
|
||||
currentUserId: "user-1",
|
||||
};
|
||||
|
||||
const zeroResultsFixture: CompanySearchZeroResults = {
|
||||
unfilteredTotal: 42,
|
||||
loosenSuggestions: [
|
||||
{ filter: "status", values: ["in_progress", "todo"], resultCount: 30, additionalCount: 30 },
|
||||
{ filter: "priority", values: ["high"], resultCount: 12, additionalCount: 12 },
|
||||
{ filter: "updatedWithin", values: ["7d"], resultCount: 6, additionalCount: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
function SearchStories() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
|
|
@ -516,6 +628,14 @@ function SearchStories() {
|
|||
<SearchPagePreview response={fixtureResponse} state="results" query="auth flake" />
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="paperclip-story__title-block">
|
||||
<div className="paperclip-story__label">/search · screen 3</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Typed operators, pills & autocomplete</h2>
|
||||
</div>
|
||||
<SearchOperatorInputPreview />
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="paperclip-story__title-block">
|
||||
<div className="paperclip-story__label">/search</div>
|
||||
|
|
@ -537,7 +657,49 @@ function SearchStories() {
|
|||
<div className="paperclip-story__label">/search</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">No results state</h2>
|
||||
</div>
|
||||
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
|
||||
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="paperclip-story__title-block">
|
||||
<div className="paperclip-story__label">/search · screen 1</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Filter bar, active chips & honest meta</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-border bg-background p-4">
|
||||
<SearchFilterBar
|
||||
filters={activeSearchFilters}
|
||||
onChange={noop}
|
||||
sort="relevance"
|
||||
onSortChange={noop}
|
||||
data={searchFilterData}
|
||||
/>
|
||||
<SearchFilterChips
|
||||
filters={activeSearchFilters}
|
||||
lookups={searchFilterLookups}
|
||||
onChange={noop}
|
||||
onClearAll={noop}
|
||||
/>
|
||||
<div className="py-1 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
8 of 42 results · sorted by Relevance · 4 filters active
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="paperclip-story__title-block">
|
||||
<div className="paperclip-story__label">/search · screen 4</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Zero-results recovery</h2>
|
||||
</div>
|
||||
<div className="border-t border-border bg-background">
|
||||
<ZeroResultsRecovery
|
||||
query="auth flake"
|
||||
filters={activeSearchFilters}
|
||||
zeroResults={zeroResultsFixture}
|
||||
lookups={searchFilterLookups}
|
||||
onChange={noop}
|
||||
onClearAll={noop}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden p-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue