1
0
Fork 0
polaris-task-force/src/components/frontend/projects/ProjectSearchView.tsx

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>
);
}