Files
sprint/packages/frontend/src/components/issue-detail-pane.tsx
2026-01-13 16:00:56 +00:00

312 lines
12 KiB
TypeScript

import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@issue/shared";
import { Check, Link, Trash, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag";
import { TimerDisplay } from "@/components/timer-display";
import { TimerModal } from "@/components/timer-modal";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { SelectTrigger } from "@/components/ui/select";
import { UserSelect } from "@/components/user-select";
import { issue } from "@/lib/server";
import { issueID } from "@/lib/utils";
import SmallSprintDisplay from "./small-sprint-display";
import { SprintSelect } from "./sprint-select";
export function IssueDetailPane({
project,
sprints,
issueData,
members,
statuses,
close,
onIssueUpdate,
onIssueDelete,
}: {
project: ProjectResponse;
sprints: SprintRecord[];
issueData: IssueResponse;
members: UserRecord[];
statuses: Record<string, string>;
close: () => void;
onIssueUpdate?: () => void;
onIssueDelete?: (issueId: number) => void | Promise<void>;
}) {
const { user } = useSession();
const [assigneeId, setAssigneeId] = useState<string>(
issueData.Issue.assigneeId?.toString() ?? "unassigned",
);
const [sprintId, setSprintId] = useState<string>(issueData.Issue.sprintId?.toString() ?? "unassigned");
const [status, setStatus] = useState<string>(issueData.Issue.status);
const [deleteOpen, setDeleteOpen] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned");
setStatus(issueData.Issue.status);
}, [issueData.Issue.sprintId, issueData.Issue.assigneeId, issueData.Issue.status]);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
window.clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const handleSprintChange = async (value: string) => {
setSprintId(value);
const newSprintId = value === "unassigned" ? null : Number(value);
await issue.update({
issueId: issueData.Issue.id,
sprintId: newSprintId,
onSuccess: () => {
onIssueUpdate?.();
toast.success(
<>
Successfully updated sprint to{" "}
{value === "unassigned" ? (
"Unassigned"
) : (
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
)}{" "}
for {issueID(project.Project.key, issueData.Issue.number)}
</>,
{
dismissible: false,
},
);
},
onError: (error) => {
console.error("error updating sprint:", error);
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
toast.error(
<>
Error updating sprint to{" "}
{value === "unassigned" ? (
"Unassigned"
) : (
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
)}{" "}
for {issueID(project.Project.key, issueData.Issue.number)}
</>,
{
dismissible: false,
},
);
},
});
};
const handleAssigneeChange = async (value: string) => {
setAssigneeId(value);
const newAssigneeId = value === "unassigned" ? null : Number(value);
await issue.update({
issueId: issueData.Issue.id,
assigneeId: newAssigneeId,
onSuccess: () => {
const user = members.find((member) => member.id === newAssigneeId);
toast.success(
<div className={"flex items-center gap-2"}>
Assigned {user ? <SmallUserDisplay user={user} className={"text-sm"} /> : "unknown"}{" "}
to {issueID(project.Project.key, issueData.Issue.number)}
</div>,
{
dismissible: false,
},
);
onIssueUpdate?.();
},
onError: (error) => {
console.error("error updating assignee:", error);
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned");
toast.error(`Error updating assignee: ${error}`, {
dismissible: false,
});
},
});
};
const handleStatusChange = async (value: string) => {
setStatus(value);
await issue.update({
issueId: issueData.Issue.id,
status: value,
onSuccess: () => {
toast.success(
<>
{issueID(project.Project.key, issueData.Issue.number)}'s status updated to{" "}
<StatusTag status={value} colour={statuses[value]} />
</>,
{ dismissible: false },
);
onIssueUpdate?.();
},
onError: (error) => {
console.error("error updating status:", error);
setStatus(issueData.Issue.status);
toast.error(`Error updating status: ${error}`, {
dismissible: false,
});
},
});
};
const handleDelete = () => {
setDeleteOpen(true);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setLinkCopied(true);
if (copyTimeoutRef.current) {
window.clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = window.setTimeout(() => {
setLinkCopied(false);
copyTimeoutRef.current = null;
}, 1500);
} catch (error) {
console.error("error copying issue link:", error);
}
};
const handleConfirmDelete = async () => {
await issue.delete({
issueId: issueData.Issue.id,
onSuccess: async () => {
await onIssueDelete?.(issueData.Issue.id);
toast.success(`Deleted issue ${issueID(project.Project.key, issueData.Issue.number)}`, {
dismissible: false,
});
},
onError: (error) => {
console.error(
`error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}`,
error,
);
toast.error(
`Error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}: ${error}`,
{
dismissible: false,
},
);
},
});
setDeleteOpen(false);
};
return (
<div className="flex flex-col">
<div className="flex flex-row items-center justify-end border-b h-[25px]">
<span className="w-full">
<p className="text-sm w-fit px-1 font-700">
{issueID(project.Project.key, issueData.Issue.number)}
</p>
</span>
<div className="flex items-center">
<Button
variant="dummy"
onClick={handleCopyLink}
className="px-0 py-0 w-6 h-6 hover:text-foreground/70"
title={linkCopied ? "Copied" : "Copy link"}
>
{linkCopied ? <Check /> : <Link />}
</Button>
<Button
variant="dummy"
onClick={handleDelete}
className="px-0 py-0 w-6 h-6 text-destructive hover:text-destructive/70"
>
<Trash />
</Button>
<Button variant={"dummy"} onClick={close} className="px-0 py-0 w-6 h-6">
<X />
</Button>
</div>
</div>
<div className="flex flex-col w-full p-2 py-2 gap-2">
<div className="flex gap-2">
<StatusSelect
statuses={statuses}
value={status}
onChange={handleStatusChange}
trigger={({ isOpen, value }) => (
<SelectTrigger
className="group w-auto flex items-center"
variant="unstyled"
chevronClassName="hidden"
isOpen={isOpen}
>
<StatusTag
status={value}
colour={statuses[value]}
className="hover:opacity-85"
/>
</SelectTrigger>
)}
/>
<div className="flex w-full items-center min-w-0">
<span className="block w-full truncate">{issueData.Issue.title}</span>
</div>
</div>
{issueData.Issue.description !== "" && (
<p className="text-sm">{issueData.Issue.description}</p>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Assignee:</span>
<UserSelect
users={members}
value={assigneeId}
onChange={handleAssigneeChange}
fallbackUser={issueData.Assignee}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Created by:</span>
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
</div>
<div className="flex items-center gap-2">
{user?.id === Number(assigneeId) && <TimerModal issueId={issueData.Issue.id} />}
<TimerDisplay issueId={issueData.Issue.id} />
</div>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
onConfirm={handleConfirmDelete}
title="Delete issue"
message="This will permanently delete the issue."
processingText="Deleting..."
confirmText="Delete"
variant="destructive"
/>
</div>
</div>
);
}