diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index 3b3e2d2..ab4c787 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -1,455 +1,38 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; +import { useMemo } from "react"; +import { IssueDetails } from "@/components/issue-details"; import { useSelection } from "@/components/selection-provider"; -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, useOrganisationMembers, useSelectedIssue, useSelectedOrganisation, useSelectedProject, useSprints, - 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 IssueDetailPane() { - const { user } = useSession(); const { selectIssue } = useSelection(); const selectedOrganisation = useSelectedOrganisation(); const selectedProject = useSelectedProject(); const issueData = useSelectedIssue(); const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); - const updateIssue = useUpdateIssue(); - const deleteIssue = useDeleteIssue(); const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const statuses = selectedOrganisation?.Organisation.statuses ?? {}; - 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(() => { - if (!issueData) return; - 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); - } - }; - }, []); - if (!issueData || !selectedProject || !selectedOrganisation) { return null; } - 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(selectedProject.Project.key, 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(selectedProject.Project.key, 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(selectedProject.Project.key, 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(selectedProject.Project.key, 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(selectedProject.Project.key, 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(selectedProject.Project.key, 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); - selectIssue(null); - toast.success(`Deleted issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}`, { - dismissible: false, - }); - } catch (error) { - console.error( - `error deleting issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}`, - error, - ); - toast.error( - `Error deleting issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}: ${parseError( - error as Error, - )}`, - { - dismissible: false, - }, - ); - } finally { - setDeleteOpen(false); - } - }; - return ( -
-
- -

- {issueID(selectedProject.Project.key, issueData.Issue.number)} -

-
-
- - {linkCopied ? : } - - - - - selectIssue(null)} title={"Close"}> - - -
-
- -
-
- ( - - - - )} - /> -
- 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 ? ( -