import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { IssueComments } from "@/components/issue-comments"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { useSession } from "@/components/session-provider"; import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallUserDisplay from "@/components/small-user-display"; import { SprintSelect } from "@/components/sprint-select"; 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 Icon from "@/components/ui/icon"; import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { SelectTrigger } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useDeleteIssue, useUpdateIssue } from "@/lib/query/hooks"; import { parseError } from "@/lib/server"; import { cn, issueID } from "@/lib/utils"; function assigneesToStringArray(assignees: { id: number }[]): string[] { if (assignees.length === 0) return ["unassigned"]; return assignees.map((a) => a.id.toString()); } function stringArrayToAssigneeIds(assigneeIds: string[]): number[] { return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); } export function IssueDetails({ issueData, projectKey, sprints, members, statuses, onClose, onDelete, showHeader = true, }: { issueData: IssueResponse; projectKey: string; sprints: SprintRecord[]; members: UserRecord[]; statuses: Record; onClose: () => void; onDelete?: () => void; showHeader?: boolean; }) { const { user } = useSession(); const updateIssue = useUpdateIssue(); const deleteIssue = useDeleteIssue(); const [assigneeIds, setAssigneeIds] = useState([]); const [sprintId, setSprintId] = useState("unassigned"); const [status, setStatus] = useState(""); const [deleteOpen, setDeleteOpen] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const copyTimeoutRef = useRef(null); const [title, setTitle] = useState(""); const [originalTitle, setOriginalTitle] = useState(""); const [isSavingTitle, setIsSavingTitle] = useState(false); const [description, setDescription] = useState(""); const [originalDescription, setOriginalDescription] = useState(""); const [isEditingDescription, setIsEditingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false); const descriptionRef = useRef(null); const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); const hasMultipleAssignees = actualAssigneeIds.length > 1; useEffect(() => { setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setStatus(issueData.Issue.status); setTitle(issueData.Issue.title); setOriginalTitle(issueData.Issue.title); setDescription(issueData.Issue.description); setOriginalDescription(issueData.Issue.description); setIsEditingDescription(false); }, [issueData]); useEffect(() => { return () => { if (copyTimeoutRef.current) { window.clearTimeout(copyTimeoutRef.current); } }; }, []); const handleSprintChange = async (value: string) => { setSprintId(value); const newSprintId = value === "unassigned" ? null : Number(value); try { await updateIssue.mutateAsync({ id: issueData.Issue.id, sprintId: newSprintId, }); toast.success( <> Successfully updated sprint to{" "} {value === "unassigned" ? ( "Unassigned" ) : ( s.id === newSprintId)} /> )}{" "} for {issueID(projectKey, issueData.Issue.number)} , { dismissible: false, }, ); } catch (error) { console.error("error updating sprint:", error); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); toast.error( <> Error updating sprint to{" "} {value === "unassigned" ? ( "Unassigned" ) : ( s.id === newSprintId)} /> )}{" "} for {issueID(projectKey, issueData.Issue.number)} , { dismissible: false, }, ); } }; const handleAssigneeChange = async (newAssigneeIds: string[]) => { const previousAssigneeIds = assigneeIds; setAssigneeIds(newAssigneeIds); const newAssigneeIdNumbers = stringArrayToAssigneeIds(newAssigneeIds); const previousAssigneeIdNumbers = stringArrayToAssigneeIds(previousAssigneeIds); const hasChanged = newAssigneeIdNumbers.length !== previousAssigneeIdNumbers.length || !newAssigneeIdNumbers.every((id) => previousAssigneeIdNumbers.includes(id)); if (!hasChanged) { return; } try { await updateIssue.mutateAsync({ id: issueData.Issue.id, assigneeIds: newAssigneeIdNumbers, }); const assignedUsers = members.filter((member) => newAssigneeIdNumbers.includes(member.id)); const displayText = assignedUsers.length === 0 ? "Unassigned" : assignedUsers.length === 1 ? assignedUsers[0].name : `${assignedUsers.length} assignees`; toast.success(
Updated assignees to {displayText} for {issueID(projectKey, issueData.Issue.number)}
, { dismissible: false, }, ); } catch (error) { console.error("error updating assignees:", error); setAssigneeIds(previousAssigneeIds); toast.error(`Error updating assignees: ${parseError(error as Error)}`, { dismissible: false, }); } }; const handleStatusChange = async (value: string) => { setStatus(value); try { await updateIssue.mutateAsync({ id: issueData.Issue.id, status: value, }); toast.success( <> {issueID(projectKey, issueData.Issue.number)}'s status updated to{" "} , { dismissible: false }, ); } catch (error) { console.error("error updating status:", error); setStatus(issueData.Issue.status); toast.error(`Error updating status: ${parseError(error as 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 handleTitleSave = async () => { const trimmedTitle = title.trim(); if (trimmedTitle === "" || trimmedTitle === originalTitle) { setTitle(originalTitle); return; } setIsSavingTitle(true); try { await updateIssue.mutateAsync({ id: issueData.Issue.id, title: trimmedTitle, }); setOriginalTitle(trimmedTitle); toast.success(`${issueID(projectKey, issueData.Issue.number)} Title updated`); } catch (error) { console.error("error updating title:", error); setTitle(originalTitle); } finally { setIsSavingTitle(false); } }; const handleDescriptionSave = async () => { const trimmedDescription = description.trim(); if (trimmedDescription === originalDescription) { if (trimmedDescription === "") { setIsEditingDescription(false); } return; } setIsSavingDescription(true); try { await updateIssue.mutateAsync({ id: issueData.Issue.id, description: trimmedDescription, }); setOriginalDescription(trimmedDescription); setDescription(trimmedDescription); toast.success(`${issueID(projectKey, issueData.Issue.number)} Description updated`); if (trimmedDescription === "") { setIsEditingDescription(false); } } catch (error) { console.error("error updating description:", error); setDescription(originalDescription); } finally { setIsSavingDescription(false); } }; const handleConfirmDelete = async () => { try { await deleteIssue.mutateAsync(issueData.Issue.id); onDelete?.(); toast.success(`Deleted issue ${issueID(projectKey, issueData.Issue.number)}`, { dismissible: false, }); } catch (error) { console.error(`error deleting issue ${issueID(projectKey, issueData.Issue.number)}`, error); toast.error( `Error deleting issue ${issueID(projectKey, issueData.Issue.number)}: ${parseError(error as Error)}`, { dismissible: false, }, ); } finally { setDeleteOpen(false); } }; return (
{showHeader && (

{issueID(projectKey, issueData.Issue.number)}

{linkCopied ? : }
)}
( )} />
setTitle(event.target.value)} onBlur={handleTitleSave} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } else if (event.key === "Escape") { setTitle(originalTitle); event.currentTarget.blur(); } }} disabled={isSavingTitle} className={cn( "w-full border-0 border-b-1 border-b-input/50", "hover:border-b-input focus:border-b-input h-auto", )} inputClassName={cn("bg-background px-1.5 font-600")} />
{description || isEditingDescription ? (