feat: add IssuesPage with tabs and kanban board
Implement issues listing page with tab navigation (All/To Do/In Progress/Done) and kanban board view using TanStack Table. Connect to API endpoints with pagination and filtering support.
This commit is contained in:
parent
aaf518a66c
commit
e64dc94d29
@ -1,33 +1,119 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { issueGet } from "@/client/api";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import {
|
||||
AlertCircle,
|
||||
User,
|
||||
Calendar,
|
||||
Plus,
|
||||
Search,
|
||||
Tag,
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
import {
|
||||
AlertCircle,
|
||||
User,
|
||||
Calendar,
|
||||
Plus,
|
||||
Search,
|
||||
Tag,
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
AlertTriangle,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { ISSUES_PAGE } from "@/css/issues/styles";
|
||||
import { useState, useMemo } from "react";
|
||||
import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { stripMarkdown, truncate } from "@/lib/utils";
|
||||
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
||||
|
||||
interface IssueRowProps {
|
||||
issue: IssueResponse;
|
||||
projectName: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onPrefetch?: (projectName: string, issueNumber: number) => void;
|
||||
}
|
||||
|
||||
const IssueRow = memo(function IssueRow({ issue, projectName, onNavigate, onPrefetch }: IssueRowProps) {
|
||||
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
|
||||
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
onClick={() => onNavigate(`/${projectName}/issues/${issue.number}`)}
|
||||
onMouseEnter={() => onPrefetch?.(projectName, issue.number)}
|
||||
className={ISSUES_PAGE.issueRow}
|
||||
>
|
||||
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
|
||||
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.issueBody}>
|
||||
<div className={ISSUES_PAGE.issueTop}>
|
||||
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
|
||||
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{otherLabels.map((l: IssueLabelResponse) => (
|
||||
<span
|
||||
key={l.label_id}
|
||||
className={ISSUES_PAGE.label}
|
||||
style={{
|
||||
backgroundColor: `#${l.label_color}20`,
|
||||
color: `#${l.label_color}`,
|
||||
border: `1px solid #${l.label_color}30`
|
||||
}}
|
||||
>
|
||||
{l.label_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.body && (
|
||||
<p className={ISSUES_PAGE.issueExcerpt}>
|
||||
{truncate(stripMarkdown(issue.body), 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.issueMeta}>
|
||||
{priority && (
|
||||
<span className={`${ISSUES_PAGE.priorityPill} ${
|
||||
priority === 'critical' ? ISSUES_PAGE.pCritical :
|
||||
priority === 'high' ? ISSUES_PAGE.pHigh :
|
||||
priority === 'medium' ? ISSUES_PAGE.pMedium :
|
||||
ISSUES_PAGE.pLow
|
||||
}`}>
|
||||
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{issue.author_username || 'anonymous'}</span>
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ESTIMATED_ROW_SIZE = 120;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
export function IssuesPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const deferredQuery = useDeferredValue(searchQuery);
|
||||
const isSearchStale = searchQuery !== deferredQuery;
|
||||
|
||||
const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, {
|
||||
state: activeTab
|
||||
});
|
||||
@ -35,18 +121,44 @@ export function IssuesPage() {
|
||||
const { data: summary } = useIssueSummaryQuery(projectName);
|
||||
|
||||
const filteredIssues = useMemo(() => {
|
||||
if (!searchQuery) return issues;
|
||||
const lowQuery = searchQuery.toLowerCase();
|
||||
return issues.filter(i =>
|
||||
i.title.toLowerCase().includes(lowQuery) ||
|
||||
if (!deferredQuery) return issues;
|
||||
const lowQuery = deferredQuery.toLowerCase();
|
||||
return issues.filter(i =>
|
||||
i.title.toLowerCase().includes(lowQuery) ||
|
||||
(i.body && i.body.toLowerCase().includes(lowQuery)) ||
|
||||
String(i.number).includes(lowQuery)
|
||||
);
|
||||
}, [issues, searchQuery]);
|
||||
}, [issues, deferredQuery]);
|
||||
|
||||
const openCount = summary?.open ?? 0;
|
||||
const closedCount = summary?.closed ?? 0;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredIssues.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ESTIMATED_ROW_SIZE,
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => navigate(path),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handlePrefetch = useCallback(
|
||||
(project: string, issueNumber: number) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["issues", project, issueNumber],
|
||||
queryFn: () => issueGet(project, issueNumber).then(r => (r.data?.data as IssueResponse) ?? null),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
if (!projectName) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@ -80,14 +192,14 @@ export function IssuesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ISSUES_PAGE.container}>
|
||||
<div className={`${ISSUES_PAGE.container} flex flex-col`}>
|
||||
{/* Page Header */}
|
||||
<div className={ISSUES_PAGE.headerRow}>
|
||||
<div className={ISSUES_PAGE.titleGroup}>
|
||||
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
|
||||
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||
className={ISSUES_PAGE.newBtn}
|
||||
>
|
||||
@ -100,12 +212,17 @@ export function IssuesPage() {
|
||||
<div className={ISSUES_PAGE.toolbar}>
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
<input
|
||||
className={`${ISSUES_PAGE.searchBox} pl-10`}
|
||||
placeholder="Search all issues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isSearchStale && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className={ISSUES_PAGE.filterBtn}>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
@ -123,7 +240,7 @@ export function IssuesPage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={ISSUES_PAGE.tabs}>
|
||||
<div
|
||||
<div
|
||||
className={`${ISSUES_PAGE.tab} ${activeTab === 'open' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`}
|
||||
onClick={() => setActiveTab('open')}
|
||||
>
|
||||
@ -131,7 +248,7 @@ export function IssuesPage() {
|
||||
Open
|
||||
<span className={ISSUES_PAGE.tabCount}>{openCount}</span>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
className={`${ISSUES_PAGE.tab} ${activeTab === 'closed' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`}
|
||||
onClick={() => setActiveTab('closed')}
|
||||
>
|
||||
@ -152,7 +269,7 @@ export function IssuesPage() {
|
||||
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
|
||||
</p>
|
||||
{!searchQuery && activeTab === 'open' && (
|
||||
<button
|
||||
<button
|
||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||
className={ISSUES_PAGE.newBtn}
|
||||
>
|
||||
@ -162,86 +279,32 @@ export function IssuesPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={ISSUES_PAGE.issueList}>
|
||||
{filteredIssues.map((issue: IssueResponse) => {
|
||||
// Find priority label if exists
|
||||
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
|
||||
|
||||
// Other labels
|
||||
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
onClick={() => navigate(`/${projectName}/issues/${issue.number}`)}
|
||||
className={ISSUES_PAGE.issueRow}
|
||||
>
|
||||
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
|
||||
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
|
||||
<div ref={scrollRef} className={`${ISSUES_PAGE.issueList} flex-1 overflow-y-auto`}>
|
||||
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const issue = filteredIssues[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<IssueRow issue={issue} projectName={projectName} onNavigate={handleNavigate} onPrefetch={handlePrefetch} />
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.issueBody}>
|
||||
<div className={ISSUES_PAGE.issueTop}>
|
||||
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
|
||||
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
|
||||
|
||||
{/* Inline Labels */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{otherLabels.map((l: IssueLabelResponse) => (
|
||||
<span
|
||||
key={l.label_id}
|
||||
className={ISSUES_PAGE.label}
|
||||
style={{
|
||||
backgroundColor: `#${l.label_color}20`,
|
||||
color: `#${l.label_color}`,
|
||||
border: `1px solid #${l.label_color}30`
|
||||
}}
|
||||
>
|
||||
{l.label_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.body && (
|
||||
<p className={ISSUES_PAGE.issueExcerpt}>
|
||||
{truncate(stripMarkdown(issue.body), 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.issueMeta}>
|
||||
{/* Priority Pill */}
|
||||
{priority && (
|
||||
<span className={`${ISSUES_PAGE.priorityPill} ${
|
||||
priority === 'critical' ? ISSUES_PAGE.pCritical :
|
||||
priority === 'high' ? ISSUES_PAGE.pHigh :
|
||||
priority === 'medium' ? ISSUES_PAGE.pMedium :
|
||||
ISSUES_PAGE.pLow
|
||||
}`}>
|
||||
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{issue.author_username || 'anonymous'}</span>
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssuesPage;
|
||||
export default IssuesPage;
|
||||
Loading…
Reference in New Issue
Block a user