From c7db20d1e8f9950ca0e8111d3816b45f6cf2629b Mon Sep 17 00:00:00 2001 From: null Date: Thu, 21 May 2026 23:01:31 -0500 Subject: [PATCH] feat(ui): issues card --- .../git/ForgejoIssueDetailDialog.tsx | 290 ++++++++++++++++++ .../src/components/git/ForgejoIssuesTable.tsx | 35 ++- frontend/src/lib/api-forgejo.ts | 13 +- 3 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/git/ForgejoIssueDetailDialog.tsx diff --git a/frontend/src/components/git/ForgejoIssueDetailDialog.tsx b/frontend/src/components/git/ForgejoIssueDetailDialog.tsx new file mode 100644 index 0000000..44c9de3 --- /dev/null +++ b/frontend/src/components/git/ForgejoIssueDetailDialog.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { ExternalLink, Loader2 } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Markdown } from "@/components/atoms/Markdown"; +import { + getForgejoIssue, + type ForgejoIssue, + type ForgejoIssueDetail, +} from "@/lib/api-forgejo"; + +type ForgejoIssueDetailDialogProps = { + issue: ForgejoIssue | null; + repositoryName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +const formatDateTime = (value: string | null | undefined): string => { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +}; + +const objectList = (value: unknown): Record[] => { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is Record => + typeof item === "object" && item !== null, + ); +}; + +const asString = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value : null; + +export function ForgejoIssueDetailDialog({ + issue, + repositoryName, + open, + onOpenChange, +}: ForgejoIssueDetailDialogProps) { + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !issue) return; + let cancelled = false; + (async () => { + setIsLoading(true); + setError(null); + try { + const result = await getForgejoIssue(issue.id); + if (!cancelled) { + setDetail(result); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Could not load issue details from Pipeline.", + ); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [issue, open]); + + const comments = useMemo( + () => objectList(detail?.forgejo_comments_payload), + [detail?.forgejo_comments_payload], + ); + const timeline = useMemo( + () => objectList(detail?.forgejo_timeline_payload), + [detail?.forgejo_timeline_payload], + ); + const reactions = useMemo( + () => objectList(detail?.forgejo_reactions_payload), + [detail?.forgejo_reactions_payload], + ); + + if (!issue) return null; + + const active = detail ?? issue; + const body = detail?.body ?? issue.body ?? issue.body_preview ?? ""; + const stateVariant = active.state === "open" ? "success" : "default"; + + return ( + + + +
+
+ + {active.title} + + + + {repositoryName} + + + #{active.forgejo_issue_number} + + {active.state} + Opened {formatDateTime(active.forgejo_created_at)} + Updated {formatDateTime(active.forgejo_updated_at)} + +
+ + Open in Forgejo + + +
+
+ + {isLoading ? ( +
+ + Loading issue details… +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + + + Overview + + Comments ({comments.length}) + + + Timeline ({timeline.length}) + + + Reactions ({reactions.length}) + + + + +
+ {body ? ( + + ) : ( +

No issue body provided.

+ )} +
+
+ + +
+ {comments.length === 0 ? ( +
+ No comments on this issue. +
+ ) : ( + comments.map((comment, idx) => { + const login = + asString( + comment.user && + (comment.user as Record).login, + ) ?? "Unknown"; + const bodyText = asString(comment.body) ?? ""; + return ( +
+
+ {login} + + {formatDateTime(asString(comment.created_at))} + +
+ {bodyText ? ( + + ) : ( +

No comment text.

+ )} +
+ ); + }) + )} +
+
+ + +
+ {timeline.length === 0 ? ( +
+ No timeline events found. +
+ ) : ( + timeline.map((event, idx) => { + const label = + asString(event.type) ?? + asString(event.action) ?? + asString(event.event) ?? + "event"; + const actor = + asString( + event.user && + (event.user as Record).login, + ) ?? "system"; + return ( +
+ {label}{" "} + by {actor} + + {formatDateTime(asString(event.created_at))} + +
+ ); + }) + )} +
+
+ + +
+ {reactions.length === 0 ? ( +
+ No reactions on this issue. +
+ ) : ( + reactions.map((reaction, idx) => { + const content = asString(reaction.content) ?? "reaction"; + const login = + asString( + reaction.user && + (reaction.user as Record).login, + ) ?? "Unknown"; + return ( +
+ {content} + {login} +
+ ); + }) + )} +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoIssuesTable.tsx b/frontend/src/components/git/ForgejoIssuesTable.tsx index 13b9a85..fa22913 100644 --- a/frontend/src/components/git/ForgejoIssuesTable.tsx +++ b/frontend/src/components/git/ForgejoIssuesTable.tsx @@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"; import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo"; import type { ForgejoRepository } from "@/lib/api-forgejo"; import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog"; +import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog"; import { cn } from "@/lib/utils"; /** Normalize a Forgejo label color to a valid 6-char hex string or null. */ @@ -195,6 +196,8 @@ export function ForgejoIssuesTable({ }: ForgejoIssuesTableProps) { const [issueToClose, setIssueToClose] = useState(null); const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); + const [issueToView, setIssueToView] = useState(null); + const [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false); const repositoryNameById = useMemo(() => { const map = new Map(); @@ -282,15 +285,17 @@ export function ForgejoIssuesTable({
- { + setIssueToView(issue); + setIsIssueDetailOpen(true); + }} > {issue.title} - + {visibleLabels.map((label, i) => ( ))} @@ -377,6 +382,22 @@ export function ForgejoIssuesTable({ }} onCloseSuccess={onRefresh} /> + { + setIsIssueDetailOpen(nextOpen); + if (!nextOpen) { + setIssueToView(null); + } + }} + /> ); } diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index abb9395..0ed35fa 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -327,6 +327,13 @@ export interface ForgejoIssue { updated_at: string; } +export interface ForgejoIssueDetail extends ForgejoIssue { + forgejo_payload?: Record | null; + forgejo_comments_payload?: Record[]; + forgejo_timeline_payload?: Record[]; + forgejo_reactions_payload?: Record[]; +} + export interface ForgejoIssueListResponse { items: ForgejoIssue[]; total: number; @@ -358,8 +365,10 @@ export async function getForgejoIssues(params?: { ); } -export async function getForgejoIssue(issueId: string): Promise { - return fetchJson(`/api/v1/forgejo/issues/${issueId}`); +export async function getForgejoIssue( + issueId: string, +): Promise { + return fetchJson(`/api/v1/forgejo/issues/${issueId}`); } export async function closeForgejoIssue(