232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { BadgeIcon, SearchIcon } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemHeader,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface SearchTicket {
|
|
readonly id: number;
|
|
readonly title: string;
|
|
readonly status: string;
|
|
readonly priority: string;
|
|
readonly type: string;
|
|
readonly storyPoints?: number | null;
|
|
readonly project: { readonly id: number; readonly name: string; readonly key: string };
|
|
readonly labels: readonly { id: number; name: string; color: string }[];
|
|
readonly assignee: { id: number; displayName: string } | null;
|
|
}
|
|
|
|
export interface LabelOption {
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly color: string;
|
|
}
|
|
|
|
export interface ProjectSearchViewProps {
|
|
readonly tickets: readonly SearchTicket[];
|
|
readonly allLabels: readonly LabelOption[];
|
|
}
|
|
|
|
const STATUS_STYLES: Readonly<Record<string, string>> = {
|
|
open: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
|
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
|
in_review: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
|
in_testing: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
|
resolved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
|
};
|
|
|
|
const PRIORITY_STYLES: Readonly<Record<string, string>> = {
|
|
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
|
medium: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
|
high: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
|
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
|
};
|
|
|
|
function labelFor(value: string): string {
|
|
return value
|
|
.replaceAll("_", " ")
|
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [selectedLabelIds, setSelectedLabelIds] = useState<readonly number[]>([]);
|
|
|
|
function toggleLabel(labelId: number) {
|
|
setSelectedLabelIds((current) =>
|
|
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
|
|
);
|
|
}
|
|
|
|
const query = search.trim().toLowerCase();
|
|
|
|
const filteredTickets = useMemo(() => {
|
|
return tickets.filter((ticket) => {
|
|
const matchesSearch = !query
|
|
|| ticket.title.toLowerCase().includes(query)
|
|
|| ticket.project.name.toLowerCase().includes(query)
|
|
|| ticket.project.key.toLowerCase().includes(query);
|
|
|
|
const matchesLabels =
|
|
selectedLabelIds.length === 0
|
|
|| ticket.labels.some((label) => selectedLabelIds.includes(label.id));
|
|
|
|
return matchesSearch && matchesLabels;
|
|
});
|
|
}, [tickets, query, selectedLabelIds]);
|
|
|
|
const groupedByProject = useMemo(() => {
|
|
const groups = new Map<string, { project: SearchTicket["project"]; tickets: SearchTicket[] }>();
|
|
for (const ticket of filteredTickets) {
|
|
const key = `${ticket.project.id}`;
|
|
const existing = groups.get(key);
|
|
if (existing) {
|
|
existing.tickets.push(ticket);
|
|
} else {
|
|
groups.set(key, { project: ticket.project, tickets: [ticket] });
|
|
}
|
|
}
|
|
return Array.from(groups.values()).sort((a, b) => a.project.name.localeCompare(b.project.name));
|
|
}, [filteredTickets]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Cross-project search
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Search tickets across all projects and filter by label.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="relative w-full lg:max-w-md">
|
|
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
aria-label="Search tickets"
|
|
className="pl-8"
|
|
placeholder="Search by title, project name, or key..."
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{allLabels.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<BadgeIcon className="size-3 text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground">Filter by label:</span>
|
|
{allLabels.map((label) => (
|
|
<button
|
|
key={label.id}
|
|
type="button"
|
|
onClick={() => toggleLabel(label.id)}
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors",
|
|
selectedLabelIds.includes(label.id)
|
|
? "border-foreground/30 bg-foreground/10 text-foreground"
|
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span
|
|
className="size-2 rounded-full"
|
|
style={{ backgroundColor: label.color }}
|
|
/>
|
|
{label.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{filteredTickets.length === 0 ? (
|
|
<Item variant="outline">
|
|
<ItemContent className="items-center gap-1">
|
|
<ItemTitle>{tickets.length === 0 ? "No tickets found" : "No matching tickets"}</ItemTitle>
|
|
<ItemDescription>
|
|
{tickets.length === 0
|
|
? "There are no project tickets yet."
|
|
: "Try a different search or label filter."}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
) : (
|
|
<div className="flex flex-col gap-4" role="region" aria-label="Search results">
|
|
{groupedByProject.map(({ project, tickets: projectTickets }) => (
|
|
<div key={project.id} className="flex flex-col gap-2">
|
|
<Link
|
|
href={`/projects/${project.key}/board`}
|
|
className="group flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Badge variant="outline" className="font-mono text-[10px]">
|
|
{project.key}
|
|
</Badge>
|
|
<span className="group-hover:underline">{project.name}</span>
|
|
<span className="text-xs text-muted-foreground">({projectTickets.length})</span>
|
|
</Link>
|
|
|
|
<div className="flex flex-col gap-1 pl-4" role="list">
|
|
{projectTickets.map((ticket) => (
|
|
<Item key={ticket.id} variant="outline" size="sm" role="listitem">
|
|
<ItemHeader className="items-center">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<ItemTitle className="truncate text-sm font-medium">{ticket.title}</ItemTitle>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"shrink-0 text-[10px] uppercase tracking-wide",
|
|
STATUS_STYLES[ticket.status] ?? "bg-muted/50 text-muted-foreground border-border",
|
|
)}
|
|
>
|
|
{labelFor(ticket.status)}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"shrink-0 text-[10px] uppercase tracking-wide",
|
|
PRIORITY_STYLES[ticket.priority] ?? "bg-muted/50 text-muted-foreground border-border",
|
|
)}
|
|
>
|
|
{labelFor(ticket.priority)}
|
|
</Badge>
|
|
</div>
|
|
</ItemHeader>
|
|
<ItemContent className="w-full min-w-0 gap-1">
|
|
{ticket.labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{ticket.labels.map((label) => (
|
|
<Badge
|
|
key={label.id}
|
|
variant="secondary"
|
|
className="text-[10px]"
|
|
>
|
|
<span
|
|
className="mr-1 size-1.5 rounded-full"
|
|
style={{ backgroundColor: label.color }}
|
|
/>
|
|
{label.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ItemContent>
|
|
</Item>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|