Pipeline/frontend/src/components/git/ForgejoIssuesTable.tsx

236 lines
6.9 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/tables/DataTable";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
function normalizeLabelColor(raw: string | null | undefined): string | null {
if (!raw) return null;
const hex = raw.replace(/^#+/, "");
return /^[0-9a-fA-F]{3,6}$/.test(hex) ? `#${hex.padEnd(6, "0")}` : null;
}
/** Return white or dark text based on WCAG relative luminance of a hex background. */
function labelTextColor(hex: string): string {
const h = hex.replace("#", "");
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const lin = (c: number) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
return L > 0.179 ? "#1a1a1a" : "#ffffff";
}
function LabelChip({ label }: { label: ForgejoIssueLabel }) {
const bg = normalizeLabelColor(label.color);
return (
<Badge
variant="outline"
className="shrink-0 text-xs"
style={
bg
? { backgroundColor: bg, color: labelTextColor(bg), borderColor: bg }
: undefined
}
title={label.description ?? label.name}
>
{label.name}
</Badge>
);
}
export type ForgejoIssuesTableProps = {
issues: ForgejoIssue[];
isLoading?: boolean;
onRefresh: () => void;
};
export function ForgejoIssuesTable({
issues,
isLoading = false,
onRefresh,
}: ForgejoIssuesTableProps) {
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const handleCloseClick = useCallback((issue: ForgejoIssue) => {
setIssueToClose(issue);
setCloseIssueDialogOpen(true);
}, []);
const handleCloseSuccess = () => {
onRefresh();
};
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
() => [
{
accessorKey: "forgejo_issue_number",
header: "#",
cell: ({ row }) => (
<a
href={row.original.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-sm text-[color:var(--accent)] hover:underline"
>
#{row.original.forgejo_issue_number}
<ExternalLink className="h-3 w-3" />
</a>
),
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<div
className="max-w-[28rem] truncate font-medium text-strong"
title={row.original.title}
>
{row.original.title}
</div>
),
},
{
accessorKey: "body_preview",
header: "Description",
cell: ({ row }) => {
const body = row.original.body_preview;
if (!body) return null;
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
return (
<div className="max-w-xs truncate text-sm text-muted" title={body}>
{truncated}
</div>
);
},
},
{
accessorKey: "state",
header: "State",
cell: ({ row }) => {
const state = row.original.state;
return (
<Badge variant={state === "open" ? "success" : "default"}>
{state}
</Badge>
);
},
},
{
accessorKey: "labels",
header: "Labels",
cell: ({ row }) => {
const labels = row.original.labels;
if (!labels || labels.length === 0) return null;
const visible = labels.slice(0, 3);
const overflow = labels.length - visible.length;
return (
<div className="flex max-w-[240px] flex-wrap gap-1">
{visible.map((label, i) => (
<LabelChip key={i} label={label} />
))}
{overflow > 0 && (
<span className="self-center text-xs text-muted">
+{overflow}
</span>
)}
</div>
);
},
},
{
accessorKey: "author",
header: "Author",
cell: ({ row }) => (
<span className="block max-w-[160px] truncate text-muted">
{row.original.author || "Unknown"}
</span>
),
},
{
accessorKey: "forgejo_updated_at",
header: "Updated",
cell: ({ row }) => {
try {
return (
<span className="whitespace-nowrap text-muted">
{new Date(row.original.forgejo_updated_at).toLocaleDateString()}
</span>
);
} catch {
return (
<span className="text-muted">
{row.original.forgejo_updated_at}
</span>
);
}
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const issue = row.original;
if (issue.state !== "open" || issue.is_pull_request) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={() => handleCloseClick(issue)}
title="Close issue"
>
<XCircle className="h-4 w-4" />
</Button>
);
},
},
],
[handleCloseClick],
);
const table = useReactTable({
data: issues,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
<DataTable
table={table}
isLoading={isLoading}
loadingLabel="Loading Git Project issues…"
tableClassName="min-w-[960px] w-full text-left text-sm"
emptyState={{
icon: <CircleDot className="h-12 w-12" />,
title: "No Git Project issues found",
description:
"Sync a repository to pull issues into Pipeline, or adjust your filters.",
}}
/>
</div>
<CloseForgejoIssueDialog
issue={issueToClose}
open={closeIssueDialogOpen}
onOpenChange={setCloseIssueDialogOpen}
onCloseSuccess={handleCloseSuccess}
/>
</>
);
}