diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index e8d8400e..86389051 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@/shared/types"; @@ -8,7 +8,6 @@ import type { Issue } from "@/shared/types"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/issues", - useSearchParams: () => new URLSearchParams(), })); // Mock next/link @@ -354,35 +353,4 @@ describe("IssuesPage", () => { // Should still render the board/list view, not a "no issues" message expect(screen.queryByText("No matching issues")).not.toBeInTheDocument(); }); - - it("does not commit pinyin composition text before IME composition ends", () => { - mockStoreState.loading = false; - mockStoreState.issues = mockIssues; - - const replaceStateSpy = vi - .spyOn(window.history, "replaceState") - .mockImplementation(() => undefined); - - render(); - - const input = screen.getByLabelText("Search issues"); - - fireEvent.compositionStart(input); - fireEvent.change(input, { target: { value: "kaihui" } }); - - expect(input).toHaveValue("kaihui"); - expect(replaceStateSpy).not.toHaveBeenCalled(); - - fireEvent.change(input, { target: { value: "开会" } }); - fireEvent.compositionEnd(input, { data: "开会" }); - - expect(input).toHaveValue("开会"); - expect(replaceStateSpy).toHaveBeenCalledWith( - null, - "", - "/issues?q=%E5%BC%80%E4%BC%9A", - ); - - replaceStateSpy.mockRestore(); - }); }); diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index babfa196..cdd95147 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -9,15 +9,12 @@ import { CircleDot, Columns3, Filter, - LoaderCircle, List, - Search, SignalHigh, SlidersHorizontal, User, UserMinus, UserPen, - X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -38,13 +35,6 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, - InputGroupText, -} from "@/components/ui/input-group"; import { Switch } from "@/components/ui/switch"; import { ALL_STATUSES, @@ -285,23 +275,7 @@ function ActorSubContent({ // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader({ - scopedIssues, - searchQuery, - searchLoading, - resultCount, - onSearchQueryChange, - onSearchCompositionStart, - onSearchCompositionEnd, -}: { - scopedIssues: Issue[]; - searchQuery: string; - searchLoading: boolean; - resultCount: number; - onSearchQueryChange: (value: string) => void; - onSearchCompositionStart: () => void; - onSearchCompositionEnd: (value: string) => void; -}) { +export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); @@ -331,9 +305,9 @@ export function IssuesHeader({ SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; return ( -
+
{/* Left: scope buttons */} -
+
{SCOPES.map((s) => ( -
- - - - - onSearchQueryChange(event.target.value)} - onCompositionStart={onSearchCompositionStart} - onCompositionEnd={(event) => onSearchCompositionEnd(event.currentTarget.value)} - placeholder="Search issues, #123, status:done, assignee:alice" - aria-label="Search issues" - /> - - {searchLoading ? ( - - ) : searchQuery ? ( - - {resultCount} {resultCount === 1 ? "match" : "matches"} - - ) : null} - {searchQuery ? ( - onSearchQueryChange("")} - > - - - ) : null} - - -
- {/* Right: filter + display + view toggle */} -
+
{/* Filter */} diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index 2f46c415..45165384 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -1,56 +1,32 @@ "use client"; -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo } from "react"; import { toast } from "sonner"; import { ChevronRight, ListTodo } from "lucide-react"; -import type { Issue, IssueStatus } from "@/shared/types"; +import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store"; import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; import { filterIssues } from "@/features/issues/utils/filter"; -import { - filterIssuesBySearch, - getSearchConstrainedStatuses, - parseIssueSearch, -} from "@/features/issues/utils/search"; -import { ALL_STATUSES, BOARD_STATUSES } from "@/features/issues/config"; +import { BOARD_STATUSES } from "@/features/issues/config"; import { useWorkspaceStore } from "@/features/workspace"; import { WorkspaceAvatar } from "@/features/workspace"; import { useWorkspaceId } from "@core/hooks"; import { issueListOptions } from "@core/issues/queries"; import { useUpdateIssue } from "@core/issues/mutations"; -import { api } from "@/shared/api"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { IssuesHeader } from "./issues-header"; import { BoardView } from "./board-view"; import { ListView } from "./list-view"; import { BatchActionToolbar } from "./batch-action-toolbar"; -function mergeIssuesById(base: Issue[] | null, live: Issue[]): Issue[] { - const byId = new Map(); - - for (const issue of base ?? []) { - byId.set(issue.id, issue); - } - - for (const issue of live) { - byId.set(issue.id, issue); - } - - return Array.from(byId.values()); -} - export function IssuesPage() { const wsId = useWorkspaceId(); const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); - const searchParams = useSearchParams(); - const urlSearchQuery = searchParams.get("q") ?? ""; + const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); const statusFilters = useIssueViewStore((s) => s.statusFilters); @@ -58,171 +34,38 @@ export function IssuesPage() { const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters); const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee); const creatorFilters = useIssueViewStore((s) => s.creatorFilters); - const [searchInputValue, setSearchInputValue] = useState(urlSearchQuery); - const [searchQuery, setSearchQueryState] = useState(urlSearchQuery); - const isSearchComposingRef = useRef(false); - const [searchPool, setSearchPool] = useState(null); - const [searchPoolWorkspaceId, setSearchPoolWorkspaceId] = useState(null); - const [searchLoading, setSearchLoading] = useState(false); - const deferredSearchQuery = useDeferredValue(searchQuery); - const hasActiveSearch = deferredSearchQuery.trim().length > 0; - const hasActiveFilters = - statusFilters.length > 0 || - priorityFilters.length > 0 || - assigneeFilters.length > 0 || - includeNoAssignee || - creatorFilters.length > 0; - - const replaceSearchUrl = useCallback((nextQuery: string) => { - const params = new URLSearchParams(window.location.search); - if (nextQuery.trim()) { - params.set("q", nextQuery); - } else { - params.delete("q"); - } - - const next = params.toString(); - window.history.replaceState(null, "", next ? `/issues?${next}` : "/issues"); - }, []); - - const applySearchQuery = useCallback((nextQuery: string) => { - setSearchQueryState(nextQuery); - replaceSearchUrl(nextQuery); - }, [replaceSearchUrl]); - - const handleSearchInputChange = useCallback((nextQuery: string) => { - setSearchInputValue(nextQuery); - if (!isSearchComposingRef.current) { - applySearchQuery(nextQuery); - } - }, [applySearchQuery]); - - const handleSearchCompositionStart = useCallback(() => { - isSearchComposingRef.current = true; - }, []); - - const handleSearchCompositionEnd = useCallback((nextQuery: string) => { - isSearchComposingRef.current = false; - setSearchInputValue(nextQuery); - applySearchQuery(nextQuery); - }, [applySearchQuery]); useEffect(() => { initFilterWorkspaceSync(); }, []); - useEffect(() => { - setSearchInputValue(urlSearchQuery); - setSearchQueryState(urlSearchQuery); - }, [urlSearchQuery]); - - useEffect(() => { - setSearchPool(null); - setSearchPoolWorkspaceId(null); - }, [workspace?.id]); - - useEffect(() => { - if (!hasActiveSearch || !workspace?.id) return; - if (searchPool && searchPoolWorkspaceId === workspace.id) return; - - let cancelled = false; - setSearchLoading(true); - - api.listIssues({ all: true }) - .then((res) => { - if (cancelled) return; - setSearchPool(res.issues); - setSearchPoolWorkspaceId(workspace.id); - }) - .catch((error) => { - if (cancelled) return; - console.error(error); - toast.error("Failed to search all issues"); - }) - .finally(() => { - if (!cancelled) setSearchLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]); - useEffect(() => { useIssueSelectionStore.getState().clear(); - }, [viewMode, scope, deferredSearchQuery]); - - const parsedSearch = useMemo( - () => parseIssueSearch(deferredSearchQuery, { members, agents }), - [agents, deferredSearchQuery, members], - ); - - const searchableIssues = useMemo(() => { - if (!hasActiveSearch) return allIssues; - if (searchPoolWorkspaceId !== workspace?.id) return allIssues; - return mergeIssuesById(searchPool, allIssues); - }, [allIssues, hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]); + }, [viewMode, scope]); // Scope pre-filter: narrow by assignee type const scopedIssues = useMemo(() => { if (scope === "members") - return searchableIssues.filter((i) => i.assignee_type === "member"); + return allIssues.filter((i) => i.assignee_type === "member"); if (scope === "agents") - return searchableIssues.filter((i) => i.assignee_type === "agent"); - return searchableIssues; - }, [scope, searchableIssues]); - - const filteredIssues = useMemo( - () => - filterIssues(scopedIssues, { - statusFilters, - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - }), - [ - assigneeFilters, - creatorFilters, - includeNoAssignee, - priorityFilters, - scopedIssues, - statusFilters, - ], - ); + return allIssues.filter((i) => i.assignee_type === "agent"); + return allIssues; + }, [allIssues, scope]); const issues = useMemo( - () => filterIssuesBySearch(filteredIssues, parsedSearch, { members, agents }), - [agents, filteredIssues, members, parsedSearch], + () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }), + [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], ); const visibleStatuses = useMemo(() => { - const explicitSearchStatuses = getSearchConstrainedStatuses(parsedSearch); - const explicitStatuses = - statusFilters.length > 0 || explicitSearchStatuses - ? ALL_STATUSES.filter((status) => { - if (statusFilters.length > 0 && !statusFilters.includes(status)) { - return false; - } - if (explicitSearchStatuses && !explicitSearchStatuses.includes(status)) { - return false; - } - return true; - }) - : null; - - if (explicitStatuses) return explicitStatuses; - if (hasActiveSearch) { - const resultStatuses = new Set(issues.map((issue) => issue.status)); - return ALL_STATUSES.filter((status) => resultStatuses.has(status)); - } + if (statusFilters.length > 0) + return BOARD_STATUSES.filter((s) => statusFilters.includes(s)); return BOARD_STATUSES; - }, [hasActiveSearch, issues, parsedSearch, statusFilters]); + }, [statusFilters]); const hiddenStatuses = useMemo(() => { - if (hasActiveSearch) return []; return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); - }, [hasActiveSearch, visibleStatuses]); + }, [visibleStatuses]); const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( @@ -284,38 +127,22 @@ export function IssuesPage() {
{/* Header 2: Scope tabs + filters */} - + {/* Content: scrollable */} - {scopedIssues.length === 0 && !hasActiveSearch && !hasActiveFilters ? ( + {scopedIssues.length === 0 ? (

No issues yet

Create an issue to get started.

- ) : issues.length === 0 ? ( -
- -

No issues match this search

-

- Try `#123`, `status:done`, `assignee:alice`, or looser keywords. -

-
) : (
{viewMode === "board" ? ( = {}): Issue { - return { - id: "i-1", - workspace_id: "ws-1", - number: 1, - identifier: "MUL-1", - title: "Test issue", - description: null, - status: "todo", - priority: "medium", - assignee_type: null, - assignee_id: null, - creator_type: "member", - creator_id: "u-1", - parent_issue_id: null, - position: 0, - due_date: null, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - ...overrides, - }; -} - -const members: Pick[] = [ - { user_id: "u-1", name: "Alice Chen" }, - { user_id: "u-2", name: "Bob Wong" }, -]; - -const agents: Pick[] = [ - { id: "a-1", name: "Fixer Bot" }, - { id: "a-2", name: "Review Agent" }, -]; - -const context = { - members, - agents, - now: new Date("2026-04-08T10:00:00Z"), -}; - -const issues: Issue[] = [ - makeIssue({ - id: "1", - number: 11, - identifier: "MUL-11", - title: "Fix login redirect loop", - description: "Users bounce back to the sign-in page.", - status: "todo", - priority: "high", - assignee_type: "member", - assignee_id: "u-1", - }), - makeIssue({ - id: "2", - number: 12, - identifier: "MUL-12", - title: "Improve issue search", - description: "Search title, description, and actor names.", - status: "in_progress", - priority: "urgent", - assignee_type: "agent", - assignee_id: "a-1", - creator_type: "agent", - creator_id: "a-2", - due_date: "2026-04-08T12:00:00Z", - }), - makeIssue({ - id: "3", - number: 18, - identifier: "MUL-18", - title: "Archive old tickets", - description: "", - status: "done", - priority: "low", - creator_id: "u-2", - due_date: "2026-04-07T09:00:00Z", - }), -]; - -describe("parseIssueSearch", () => { - it("extracts structured filters and quoted text", () => { - const parsed = parseIssueSearch( - 'status:todo priority:high assignee:"Alice" "redirect loop"', - context, - ); - - expect(parsed.statusFilters).toEqual(["todo"]); - expect(parsed.priorityFilters).toEqual(["high"]); - expect(parsed.assigneeFilters).toEqual([{ type: "member", id: "u-1" }]); - expect(parsed.textTerms).toEqual(["redirect loop"]); - }); - - it("recognizes issue numbers and lifecycle shortcuts", () => { - const parsed = parseIssueSearch("#18 is:closed", context); - - expect(parsed.issueNumber).toBe(18); - expect(parsed.lifecycle).toBe("closed"); - expect(getSearchConstrainedStatuses(parsed)).toEqual(["done", "cancelled"]); - }); -}); - -describe("filterIssuesBySearch", () => { - it("matches free text across title, description, identifier, and actor names", () => { - const parsed = parseIssueSearch("Fixer review MUL-12", context); - const result = filterIssuesBySearch(issues, parsed, context); - - expect(result.map((issue) => issue.id)).toEqual(["2"]); - }); - - it("filters by assignee, creator, and explicit status tokens", () => { - const parsed = parseIssueSearch("assignee:Fixer creator:Review status:in-progress", context); - const result = filterIssuesBySearch(issues, parsed, context); - - expect(result.map((issue) => issue.id)).toEqual(["2"]); - }); - - it("filters by due state and description presence", () => { - const todayParsed = parseIssueSearch("due:today has:description", context); - const overdueParsed = parseIssueSearch("due:overdue", context); - - expect(filterIssuesBySearch(issues, todayParsed, context).map((issue) => issue.id)).toEqual(["2"]); - expect(filterIssuesBySearch(issues, overdueParsed, context).map((issue) => issue.id)).toEqual(["3"]); - }); - - it("supports unassigned and closed search flows", () => { - const parsed = parseIssueSearch("is:closed is:unassigned", context); - const result = filterIssuesBySearch(issues, parsed, context); - - expect(result.map((issue) => issue.id)).toEqual(["3"]); - }); -}); diff --git a/apps/web/features/issues/utils/search.ts b/apps/web/features/issues/utils/search.ts deleted file mode 100644 index a950b2ad..00000000 --- a/apps/web/features/issues/utils/search.ts +++ /dev/null @@ -1,454 +0,0 @@ -import type { - Agent, - Issue, - IssuePriority, - IssueStatus, - MemberWithUser, -} from "@/shared/types"; -import { PRIORITY_CONFIG, STATUS_CONFIG, ALL_STATUSES } from "@/features/issues/config"; -import type { ActorFilterValue } from "@/features/issues/stores/view-store"; - -const CLOSED_STATUSES = new Set(["done", "cancelled"]); - -const TOKEN_REGEX = /"([^"]+)"|(\S+)/g; - -const STATUS_ALIASES: Record = { - backlog: "backlog", - todo: "todo", - "to do": "todo", - "in progress": "in_progress", - inprogress: "in_progress", - progress: "in_progress", - "in review": "in_review", - inreview: "in_review", - review: "in_review", - done: "done", - blocked: "blocked", - cancelled: "cancelled", - canceled: "cancelled", -}; - -const PRIORITY_ALIASES: Record = { - urgent: "urgent", - high: "high", - medium: "medium", - normal: "medium", - low: "low", - none: "none", - "no priority": "none", -}; - -export type IssueSearchLifecycle = "all" | "open" | "closed"; -export type IssueSearchDueState = "any" | "overdue" | "today" | "none" | "upcoming"; -export type IssueSearchAssigneeState = "any" | "assigned" | "unassigned"; - -export interface IssueSearchContext { - members: Pick[]; - agents: Pick[]; - now?: Date; -} - -export interface ParsedIssueSearch { - raw: string; - textTerms: string[]; - issueNumber: number | null; - statusFilters: IssueStatus[]; - priorityFilters: IssuePriority[]; - assigneeFilters: ActorFilterValue[]; - creatorFilters: ActorFilterValue[]; - assigneeState: IssueSearchAssigneeState; - lifecycle: IssueSearchLifecycle; - dueState: IssueSearchDueState; - hasDescription: boolean | null; - forceEmpty: boolean; -} - -function normalizeSearchText(value: string): string { - return value - .toLowerCase() - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function unwrapQuotedValue(value: string): string { - const trimmed = value.trim(); - if ( - (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1).trim(); - } - return trimmed; -} - -function addUniqueValue(items: T[], value: T) { - if (!items.includes(value)) items.push(value); -} - -function addUniqueActorFilters(target: ActorFilterValue[], next: ActorFilterValue[]) { - for (const actor of next) { - const exists = target.some( - (item) => item.type === actor.type && item.id === actor.id, - ); - if (!exists) target.push(actor); - } -} - -function parseIssueNumberToken(token: string): number | null { - const hashMatch = token.match(/^#(\d+)$/); - if (hashMatch) return Number(hashMatch[1]); - - const identifierMatch = token.match(/^[a-z][a-z0-9]*-(\d+)$/i); - if (identifierMatch) return Number(identifierMatch[1]); - - return null; -} - -function resolveStatus(value: string): IssueStatus | null { - return STATUS_ALIASES[normalizeSearchText(value)] ?? null; -} - -function resolvePriority(value: string): IssuePriority | null { - return PRIORITY_ALIASES[normalizeSearchText(value)] ?? null; -} - -function resolveActors( - value: string, - context: IssueSearchContext, -): ActorFilterValue[] { - const query = normalizeSearchText(value); - if (!query) return []; - - const matches: ActorFilterValue[] = []; - - for (const member of context.members) { - if (normalizeSearchText(member.name).includes(query)) { - matches.push({ type: "member", id: member.user_id }); - } - } - - for (const agent of context.agents) { - if (normalizeSearchText(agent.name).includes(query)) { - matches.push({ type: "agent", id: agent.id }); - } - } - - return matches; -} - -function getActorName( - type: Issue["creator_type"] | Issue["assignee_type"], - id: string | null, - context: IssueSearchContext, -): string { - if (!type || !id) return ""; - if (type === "member") { - return context.members.find((member) => member.user_id === id)?.name ?? ""; - } - return context.agents.find((agent) => agent.id === id)?.name ?? ""; -} - -function buildIssueHaystack(issue: Issue, context: IssueSearchContext): string { - const assigneeName = getActorName(issue.assignee_type, issue.assignee_id, context); - const creatorName = getActorName(issue.creator_type, issue.creator_id, context); - - return normalizeSearchText( - [ - issue.identifier, - `#${issue.number}`, - String(issue.number), - issue.title, - issue.description ?? "", - issue.status, - STATUS_CONFIG[issue.status].label, - issue.priority, - PRIORITY_CONFIG[issue.priority].label, - assigneeName, - creatorName, - ].join(" "), - ); -} - -function isSameLocalDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -export function tokenizeIssueSearch(query: string): string[] { - const tokens: string[] = []; - for (const match of query.matchAll(TOKEN_REGEX)) { - const value = (match[1] ?? match[2] ?? "").trim(); - if (value) tokens.push(value); - } - return tokens; -} - -export function parseIssueSearch( - query: string, - context: IssueSearchContext, -): ParsedIssueSearch { - const parsed: ParsedIssueSearch = { - raw: query, - textTerms: [], - issueNumber: null, - statusFilters: [], - priorityFilters: [], - assigneeFilters: [], - creatorFilters: [], - assigneeState: "any", - lifecycle: "all", - dueState: "any", - hasDescription: null, - forceEmpty: false, - }; - - for (const token of tokenizeIssueSearch(query)) { - const issueNumber = parseIssueNumberToken(token); - if (issueNumber !== null) { - if (parsed.issueNumber !== null && parsed.issueNumber !== issueNumber) { - parsed.forceEmpty = true; - } else { - parsed.issueNumber = issueNumber; - } - continue; - } - - if (token.startsWith("@")) { - const matches = resolveActors(token.slice(1), context); - if (matches.length === 0) { - parsed.forceEmpty = true; - } else { - addUniqueActorFilters(parsed.assigneeFilters, matches); - } - continue; - } - - const separatorIndex = token.indexOf(":"); - if (separatorIndex <= 0) { - parsed.textTerms.push(token); - continue; - } - - const key = token.slice(0, separatorIndex).toLowerCase(); - const rawValue = unwrapQuotedValue(token.slice(separatorIndex + 1)); - if (!rawValue) { - parsed.textTerms.push(token); - continue; - } - - switch (key) { - case "status": - case "state": { - const status = resolveStatus(rawValue); - if (!status) { - parsed.forceEmpty = true; - break; - } - addUniqueValue(parsed.statusFilters, status); - break; - } - case "priority": - case "p": { - const priority = resolvePriority(rawValue); - if (!priority) { - parsed.forceEmpty = true; - break; - } - addUniqueValue(parsed.priorityFilters, priority); - break; - } - case "assignee": - case "assigned": { - const normalizedValue = normalizeSearchText(rawValue); - if (normalizedValue === "none" || normalizedValue === "unassigned") { - parsed.assigneeState = "unassigned"; - break; - } - const matches = resolveActors(rawValue, context); - if (matches.length === 0) { - parsed.forceEmpty = true; - break; - } - addUniqueActorFilters(parsed.assigneeFilters, matches); - break; - } - case "creator": - case "author": - case "by": { - const matches = resolveActors(rawValue, context); - if (matches.length === 0) { - parsed.forceEmpty = true; - break; - } - addUniqueActorFilters(parsed.creatorFilters, matches); - break; - } - case "is": { - const normalizedValue = normalizeSearchText(rawValue); - if (normalizedValue === "open") { - parsed.lifecycle = "open"; - } else if (normalizedValue === "closed") { - parsed.lifecycle = "closed"; - } else if (normalizedValue === "assigned") { - parsed.assigneeState = "assigned"; - } else if (normalizedValue === "unassigned") { - parsed.assigneeState = "unassigned"; - } else { - parsed.forceEmpty = true; - } - break; - } - case "has": { - const normalizedValue = normalizeSearchText(rawValue); - if (normalizedValue === "description" || normalizedValue === "desc") { - parsed.hasDescription = true; - } else { - parsed.forceEmpty = true; - } - break; - } - case "due": { - const normalizedValue = normalizeSearchText(rawValue); - if ( - normalizedValue === "today" || - normalizedValue === "overdue" || - normalizedValue === "none" || - normalizedValue === "upcoming" - ) { - parsed.dueState = normalizedValue; - } else { - parsed.forceEmpty = true; - } - break; - } - default: - parsed.textTerms.push(token); - break; - } - } - - return parsed; -} - -export function getSearchConstrainedStatuses( - parsed: ParsedIssueSearch, -): IssueStatus[] | null { - if (parsed.statusFilters.length > 0) { - return ALL_STATUSES.filter((status) => parsed.statusFilters.includes(status)); - } - if (parsed.lifecycle === "open") { - return ALL_STATUSES.filter((status) => !CLOSED_STATUSES.has(status)); - } - if (parsed.lifecycle === "closed") { - return ALL_STATUSES.filter((status) => CLOSED_STATUSES.has(status)); - } - return null; -} - -export function filterIssuesBySearch( - issues: Issue[], - parsed: ParsedIssueSearch, - context: IssueSearchContext, -): Issue[] { - if (parsed.forceEmpty) return []; - - return issues.filter((issue) => { - if (parsed.issueNumber !== null && issue.number !== parsed.issueNumber) { - return false; - } - - if (parsed.lifecycle === "open" && CLOSED_STATUSES.has(issue.status)) { - return false; - } - - if (parsed.lifecycle === "closed" && !CLOSED_STATUSES.has(issue.status)) { - return false; - } - - if ( - parsed.statusFilters.length > 0 && - !parsed.statusFilters.includes(issue.status) - ) { - return false; - } - - if ( - parsed.priorityFilters.length > 0 && - !parsed.priorityFilters.includes(issue.priority) - ) { - return false; - } - - if (parsed.assigneeState === "assigned" && !issue.assignee_id) { - return false; - } - - if (parsed.assigneeState === "unassigned" && issue.assignee_id) { - return false; - } - - if (parsed.assigneeFilters.length > 0) { - if (!issue.assignee_type || !issue.assignee_id) return false; - const matchesAssignee = parsed.assigneeFilters.some( - (assignee) => - assignee.type === issue.assignee_type && - assignee.id === issue.assignee_id, - ); - if (!matchesAssignee) return false; - } - - if (parsed.creatorFilters.length > 0) { - const matchesCreator = parsed.creatorFilters.some( - (creator) => - creator.type === issue.creator_type && creator.id === issue.creator_id, - ); - if (!matchesCreator) return false; - } - - if ( - parsed.hasDescription === true && - (!issue.description || issue.description.trim().length === 0) - ) { - return false; - } - - if (parsed.dueState === "none" && issue.due_date) { - return false; - } - - if (parsed.dueState !== "any" && parsed.dueState !== "none") { - if (!issue.due_date) return false; - const dueDate = new Date(issue.due_date); - const now = parsedDateNow(context); - - if (parsed.dueState === "today" && !isSameLocalDay(dueDate, now)) { - return false; - } - - if (parsed.dueState === "overdue" && dueDate.getTime() >= now.getTime()) { - return false; - } - - if (parsed.dueState === "upcoming") { - if (dueDate.getTime() <= now.getTime() || isSameLocalDay(dueDate, now)) { - return false; - } - } - } - - if (parsed.textTerms.length === 0) return true; - - const haystack = buildIssueHaystack(issue, context); - return parsed.textTerms.every((term) => - haystack.includes(normalizeSearchText(term)), - ); - }); -} - -function parsedDateNow(context: IssueSearchContext): Date { - return context.now ? new Date(context.now) : new Date(); -} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 28bcbf46..900ba8a2 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -167,7 +167,6 @@ export class ApiClient { const search = new URLSearchParams(); if (params?.limit) search.set("limit", String(params.limit)); if (params?.offset) search.set("offset", String(params.offset)); - if (params?.all) search.set("all", "true"); const wsId = params?.workspace_id ?? this.workspaceId; if (wsId) search.set("workspace_id", wsId); if (params?.status) search.set("status", params.status); diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index c5088924..c199221f 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -28,7 +28,6 @@ export interface UpdateIssueRequest { export interface ListIssuesParams { limit?: number; offset?: number; - all?: boolean; workspace_id?: string; status?: IssueStatus; priority?: IssuePriority; diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index f714ef56..8b798bc9 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "strconv" "strings" "testing" @@ -300,57 +299,6 @@ func TestCommentCRUD(t *testing.T) { testHandler.DeleteIssue(w, req) } -func TestListIssuesAllIgnoresPagination(t *testing.T) { - createdIDs := make([]string, 0, 3) - - for i := 0; i < 3; i++ { - w := httptest.NewRecorder() - req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ - "title": "Searchable issue " + strconv.Itoa(i+1), - }) - testHandler.CreateIssue(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var issue IssueResponse - json.NewDecoder(w.Body).Decode(&issue) - createdIDs = append(createdIDs, issue.ID) - } - - t.Cleanup(func() { - for _, issueID := range createdIDs { - w := httptest.NewRecorder() - req := newRequest("DELETE", "/api/issues/"+issueID, nil) - req = withURLParam(req, "id", issueID) - testHandler.DeleteIssue(w, req) - } - }) - - w := httptest.NewRecorder() - req := newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID+"&all=true&limit=1", nil) - testHandler.ListIssues(w, req) - if w.Code != http.StatusOK { - t.Fatalf("ListIssues(all=true): expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var listResp struct { - Issues []IssueResponse `json:"issues"` - Total int `json:"total"` - } - if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { - t.Fatalf("ListIssues(all=true): decode response: %v", err) - } - - if len(listResp.Issues) < 3 { - t.Fatalf("ListIssues(all=true): expected at least 3 issues, got %d", len(listResp.Issues)) - } - - if listResp.Total != len(listResp.Issues) { - t.Fatalf("ListIssues(all=true): expected total %d, got %d", len(listResp.Issues), listResp.Total) - } -} - func TestAgentCRUD(t *testing.T) { // List agents w := httptest.NewRecorder() diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 71ed0e0a..912bed1c 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -103,31 +103,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } - if r.URL.Query().Get("all") == "true" { - issues, err := h.Queries.ListAllIssues(ctx, db.ListAllIssuesParams{ - WorkspaceID: wsUUID, - Status: statusFilterFromQuery(r), - Priority: priorityFilter, - AssigneeID: assigneeFilter, - }) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list issues") - return - } - - prefix := h.getIssuePrefix(ctx, wsUUID) - resp := make([]IssueResponse, len(issues)) - for i, issue := range issues { - resp[i] = issueToResponse(issue, prefix) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "issues": resp, - "total": len(resp), - }) - return - } - limit := 100 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { @@ -141,7 +116,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } - statusFilter := statusFilterFromQuery(r) + var statusFilter pgtype.Text + if s := r.URL.Query().Get("status"); s != "" { + statusFilter = pgtype.Text{String: s, Valid: true} + } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ WorkspaceID: wsUUID, @@ -179,14 +157,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { }) } -func statusFilterFromQuery(r *http.Request) pgtype.Text { - var statusFilter pgtype.Text - if s := r.URL.Query().Get("status"); s != "" { - statusFilter = pgtype.Text{String: s, Valid: true} - } - return statusFilter -} - func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") issue, ok := h.loadIssueForUser(w, r, id) diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index f943ddef..97ec6788 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -216,66 +216,6 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa return i, err } -const listAllIssues = `-- name: ListAllIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue -WHERE workspace_id = $1 - AND ($2::text IS NULL OR status = $2) - AND ($3::text IS NULL OR priority = $3) - AND ($4::uuid IS NULL OR assignee_id = $4) -ORDER BY position ASC, created_at DESC -` - -type ListAllIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` -} - -func (q *Queries) ListAllIssues(ctx context.Context, arg ListAllIssuesParams) ([]Issue, error) { - rows, err := q.db.Query(ctx, listAllIssues, - arg.WorkspaceID, - arg.Status, - arg.Priority, - arg.AssigneeID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Issue{} - for rows.Next() { - var i Issue - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Title, - &i.Description, - &i.Status, - &i.Priority, - &i.AssigneeType, - &i.AssigneeID, - &i.CreatorType, - &i.CreatorID, - &i.ParentIssueID, - &i.AcceptanceCriteria, - &i.ContextRefs, - &i.Position, - &i.DueDate, - &i.CreatedAt, - &i.UpdatedAt, - &i.Number, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE workspace_id = $1 diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 662a45d2..c8821ffb 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -7,14 +7,6 @@ WHERE workspace_id = $1 ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; --- name: ListAllIssues :many -SELECT * FROM issue -WHERE workspace_id = $1 - AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) -ORDER BY position ASC, created_at DESC; - -- name: GetIssue :one SELECT * FROM issue WHERE id = $1;