diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 7657d039..123c1466 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -15,6 +15,7 @@ import { BookOpenText, SquarePen, CircleUser, + Search, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; @@ -155,16 +156,27 @@ export function AppSidebar() { - - useModalStore.getState().open("create-issue")} - > - - - - New issue - +
+ + useModalStore.getState().open("search-issues")} + > + + + Search issues + + + useModalStore.getState().open("create-issue")} + > + + + + New issue + +
diff --git a/apps/web/features/modals/registry.tsx b/apps/web/features/modals/registry.tsx index faf2e63a..02b60c3d 100644 --- a/apps/web/features/modals/registry.tsx +++ b/apps/web/features/modals/registry.tsx @@ -3,6 +3,7 @@ import { useModalStore } from "./store"; import { CreateWorkspaceModal } from "./create-workspace"; import { CreateIssueModal } from "./create-issue"; +import { SearchIssuesModal } from "./search-issues"; export function ModalRegistry() { const modal = useModalStore((s) => s.modal); @@ -14,6 +15,8 @@ export function ModalRegistry() { return ; case "create-issue": return ; + case "search-issues": + return ; default: return null; } diff --git a/apps/web/features/modals/search-issues.tsx b/apps/web/features/modals/search-issues.tsx new file mode 100644 index 00000000..d8740591 --- /dev/null +++ b/apps/web/features/modals/search-issues.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + CommandDialog, + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Skeleton } from "@/components/ui/skeleton"; +import { StatusIcon } from "@/features/issues/components"; +import { api } from "@/shared/api"; +import type { Issue } from "@/shared/types"; + +function SearchSkeleton() { + return ( +
+ {Array.from({ length: 5 }, (_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +export function SearchIssuesModal({ onClose }: { onClose: () => void }) { + const router = useRouter(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const debounceRef = useRef>(null); + const composingRef = useRef(false); + + const search = useCallback(async (q: string) => { + if (!q.trim()) { + setResults([]); + setLoading(false); + return; + } + setLoading(true); + try { + const res = await api.listIssues({ search: q.trim(), limit: 20 }); + setResults(res.issues); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, []); + + const scheduleSearch = useCallback( + (q: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (q.trim()) setLoading(true); + debounceRef.current = setTimeout(() => search(q), 300); + }, + [search] + ); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const handleValueChange = (value: string) => { + setQuery(value); + if (!composingRef.current) { + scheduleSearch(value); + } + }; + + const handleCompositionStart = () => { + composingRef.current = true; + }; + + const handleCompositionEnd = (e: React.CompositionEvent) => { + composingRef.current = false; + scheduleSearch((e.target as HTMLInputElement).value); + }; + + const handleSelect = (issue: Issue) => { + onClose(); + router.push(`/issues/${issue.id}`); + }; + + return ( + { + if (!open) onClose(); + }} + title="Search Issues" + description="Search issues by title" + className="top-[min(33%,12rem)]" + > + + + + {!query.trim() ? ( +
+ Type to search issues by title +
+ ) : loading ? ( + + ) : ( + <> + No issues found + + {results.map((issue) => ( + handleSelect(issue)} + > + + {issue.title} + + {issue.identifier} + + + ))} + + + )} +
+
+
+ ); +} diff --git a/apps/web/features/modals/store.ts b/apps/web/features/modals/store.ts index f720be43..0a5a946d 100644 --- a/apps/web/features/modals/store.ts +++ b/apps/web/features/modals/store.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; -type ModalType = "create-workspace" | "create-issue" | null; +type ModalType = "create-workspace" | "create-issue" | "search-issues" | null; interface ModalStore { modal: ModalType; diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 65004b34..603be220 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -163,6 +163,7 @@ export class ApiClient { if (params?.status) search.set("status", params.status); if (params?.priority) search.set("priority", params.priority); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); + if (params?.search) search.set("search", params.search); return this.fetch(`/api/issues?${search}`); } diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index cdeeae4e..a06b3fdb 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -31,6 +31,7 @@ export interface ListIssuesParams { status?: IssueStatus; priority?: IssuePriority; assignee_id?: string; + search?: string; } export interface ListIssuesResponse { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0c5a0d6a..64123151 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -99,6 +99,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if a := r.URL.Query().Get("assignee_id"); a != "" { assigneeFilter = parseUUID(a) } + var searchFilter pgtype.Text + if s := r.URL.Query().Get("search"); s != "" { + searchFilter = pgtype.Text{String: s, Valid: true} + } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ WorkspaceID: parseUUID(workspaceID), @@ -107,6 +111,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { Status: statusFilter, Priority: priorityFilter, AssigneeID: assigneeFilter, + Search: searchFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index f899eb6e..208a3800 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -195,6 +195,7 @@ WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) AND ($5::text IS NULL OR priority = $5) AND ($6::uuid IS NULL OR assignee_id = $6) + AND ($7::text IS NULL OR title ILIKE '%' || $7 || '%') ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3 ` @@ -206,6 +207,7 @@ type ListIssuesParams struct { Status pgtype.Text `json:"status"` Priority pgtype.Text `json:"priority"` AssigneeID pgtype.UUID `json:"assignee_id"` + Search pgtype.Text `json:"search"` } func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) { @@ -216,6 +218,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue arg.Status, arg.Priority, arg.AssigneeID, + arg.Search, ) if err != nil { return nil, err diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index edc229c3..3082abab 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -4,6 +4,7 @@ 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')) + AND (sqlc.narg('search')::text IS NULL OR title ILIKE '%' || sqlc.narg('search') || '%') ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3;