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

413 lines
15 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Loader2, MessageSquarePlus, Pencil, XCircle } 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";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
import { PostForgejoCommentDialog } from "@/components/git/PostForgejoCommentDialog";
type ForgejoIssueDetailDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onRefresh?: () => void;
canClose?: boolean;
};
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<string, unknown>[] => {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
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,
onRefresh,
canClose = false,
}: ForgejoIssueDetailDialogProps) {
const [detail, setDetail] = useState<ForgejoIssueDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("overview");
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isCloseIssueDialogOpen, setIsCloseIssueDialogOpen] = useState(false);
const loadDetail = (id: string) => {
let cancelled = false;
setIsLoading(true);
setError(null);
(async () => {
try {
const result = await getForgejoIssue(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; };
};
useEffect(() => {
if (!open || !issue) return;
return loadDetail(issue.id);
}, [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";
const handleCloseIssueSuccess = () => {
if (detail) setDetail({ ...detail, state: "closed" });
if (issue) loadDetail(issue.id);
onRefresh?.();
};
const handleCommentSuccess = () => {
if (issue) loadDetail(issue.id);
onRefresh?.();
};
const handleEditSuccess = (updated: {
title: string;
body: string | null;
state: string;
}) => {
if (detail) {
setDetail({
...detail,
title: updated.title,
body: updated.body,
state: updated.state,
});
}
if (issue) loadDetail(issue.id);
onRefresh?.();
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<DialogTitle className="break-words text-base sm:text-lg">
{active.title}
</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<span className="font-medium text-strong">
{repositoryName}
</span>
<span className="font-mono">
#{active.forgejo_issue_number}
</span>
<Badge variant={stateVariant}>{active.state}</Badge>
<span>Opened {formatDateTime(active.forgejo_created_at)}</span>
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
</DialogDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl px-3 text-xs font-semibold"
onClick={() => setIsEditDialogOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl px-3 text-xs font-semibold"
onClick={() => {
setActiveTab("comments");
setIsCommentDialogOpen(true);
}}
>
<MessageSquarePlus className="h-3.5 w-3.5" />
Comment
</Button>
{canClose && active.state === "open" ? (
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl border-[color:rgba(248,113,113,0.45)] px-3 text-xs font-semibold text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={() => setIsCloseIssueDialogOpen(true)}
>
<XCircle className="h-3.5 w-3.5" />
Close Issue
</Button>
) : null}
<a
href={active.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 text-xs font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Open in Forgejo
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
</DialogHeader>
{isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
Loading issue details
</div>
) : null}
{error ? (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
{error}
</div>
) : null}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="comments">
Comments ({comments.length})
</TabsTrigger>
<TabsTrigger value="timeline">
Timeline ({timeline.length})
</TabsTrigger>
<TabsTrigger value="reactions">
Reactions ({reactions.length})
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
{body ? (
<Markdown content={body} variant="comment" />
) : (
<p className="text-sm text-muted">No issue body provided.</p>
)}
</div>
</TabsContent>
<TabsContent value="comments">
<div className="space-y-3">
{comments.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No comments yet.{" "}
<button
type="button"
className="text-[color:var(--accent)] underline-offset-2 hover:underline"
onClick={() => setIsCommentDialogOpen(true)}
>
Post the first one.
</button>
</div>
) : (
<>
{comments.map((comment, idx) => {
const login =
asString(
comment.user &&
(comment.user as Record<string, unknown>).login,
) ?? "Unknown";
const bodyText = asString(comment.body) ?? "";
return (
<article
key={String(comment.id ?? idx)}
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4"
>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="font-medium text-strong">
{login}
</span>
<span>
{formatDateTime(asString(comment.created_at))}
</span>
</div>
{bodyText ? (
<Markdown content={bodyText} variant="comment" />
) : (
<p className="text-sm text-muted">
No comment text.
</p>
)}
</article>
);
})}
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => setIsCommentDialogOpen(true)}
>
<MessageSquarePlus className="h-4 w-4" />
Post a comment
</Button>
</>
)}
</div>
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-2">
{timeline.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No timeline events found.
</div>
) : (
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<string, unknown>).login,
) ?? "system";
return (
<div
key={String(event.id ?? idx)}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">{label}</span>{" "}
<span className="text-muted">by {actor}</span>
<span className="ml-2 text-xs text-muted">
{formatDateTime(asString(event.created_at))}
</span>
</div>
);
})
)}
</div>
</TabsContent>
<TabsContent value="reactions">
<div className="space-y-2">
{reactions.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No reactions on this issue.
</div>
) : (
reactions.map((reaction, idx) => {
const content =
asString(reaction.content) ?? "reaction";
const login =
asString(
reaction.user &&
(reaction.user as Record<string, unknown>).login,
) ?? "Unknown";
return (
<div
key={String(reaction.id ?? idx)}
className="flex items-center justify-between rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">
{content}
</span>
<span className="text-xs text-muted">{login}</span>
</div>
);
})
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
</div>
</DialogContent>
</Dialog>
<CloseForgejoIssueDialog
issue={issue}
repositoryName={repositoryName}
open={isCloseIssueDialogOpen}
onOpenChange={setIsCloseIssueDialogOpen}
onCloseSuccess={handleCloseIssueSuccess}
/>
<PostForgejoCommentDialog
issue={issue}
repositoryName={repositoryName}
open={isCommentDialogOpen}
onOpenChange={setIsCommentDialogOpen}
onSuccess={handleCommentSuccess}
/>
<EditForgejoIssueDialog
issue={detail ?? issue}
repositoryName={repositoryName}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSuccess={handleEditSuccess}
/>
</>
);
}