Merge pull request #307 from multica-ai/feat/global-search
feat: add global issue search
This commit is contained in:
commit
59ebf30cf0
9 changed files with 180 additions and 11 deletions
|
|
@ -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() {
|
|||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<SquarePen className="size-3.5" />
|
||||
<DraftDot />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New issue</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => useModalStore.getState().open("search-issues")}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Search issues</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<SquarePen className="size-3.5" />
|
||||
<DraftDot />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <CreateWorkspaceModal onClose={close} />;
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} data={data} />;
|
||||
case "search-issues":
|
||||
return <SearchIssuesModal onClose={close} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
143
apps/web/features/modals/search-issues.tsx
Normal file
143
apps/web/features/modals/search-issues.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="p-2 space-y-1">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="size-4 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-3.5 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchIssuesModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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<HTMLInputElement>) => {
|
||||
composingRef.current = false;
|
||||
scheduleSearch((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const handleSelect = (issue: Issue) => {
|
||||
onClose();
|
||||
router.push(`/issues/${issue.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title="Search Issues"
|
||||
description="Search issues by title"
|
||||
className="top-[min(33%,12rem)]"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search issues..."
|
||||
value={query}
|
||||
onValueChange={handleValueChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
/>
|
||||
<CommandList className="max-h-[min(18rem,calc(100dvh-10rem))]">
|
||||
{!query.trim() ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search issues by title
|
||||
</div>
|
||||
) : loading ? (
|
||||
<SearchSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>No issues found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{results.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
onSelect={() => handleSelect(issue)}
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-4 w-4" />
|
||||
<span className="flex-1 truncate">{issue.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface ListIssuesParams {
|
|||
status?: IssueStatus;
|
||||
priority?: IssuePriority;
|
||||
assignee_id?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ListIssuesResponse {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue