From 06bac090a2fceb05753d5968f27341eb557fe4f5 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Tue, 20 Jan 2026 17:04:24 +0000 Subject: [PATCH] switched issue and timer flows to mutations --- .../src/components/issue-detail-pane.tsx | 384 +++++++++--------- .../frontend/src/components/issue-modal.tsx | 125 +++--- .../frontend/src/components/issue-timer.tsx | 79 ++-- .../frontend/src/components/timer-display.tsx | 84 ++-- 4 files changed, 305 insertions(+), 367 deletions(-) diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index fbeddcd..c52ffbc 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -1,9 +1,11 @@ -import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@sprint/shared"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; +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"; @@ -11,16 +13,23 @@ 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 { issue } from "@/lib/server"; +import { + useDeleteIssue, + useOrganisationMembers, + useSelectedIssue, + useSelectedOrganisation, + useSelectedProject, + useSprints, + useUpdateIssue, +} from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; import { cn, issueID } from "@/lib/utils"; -import SmallSprintDisplay from "./small-sprint-display"; -import { SprintSelect } from "./sprint-select"; -import { IconButton } from "./ui/icon-button"; -function assigneesToStringArray(assignees: UserRecord[]): string[] { +function assigneesToStringArray(assignees: { id: number }[]): string[] { if (assignees.length === 0) return ["unassigned"]; return assignees.map((a) => a.id.toString()); } @@ -29,44 +38,39 @@ function stringArrayToAssigneeIds(assigneeIds: string[]): number[] { return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); } -export function IssueDetailPane({ - project, - sprints, - issueData, - members, - statuses, - close, - onIssueUpdate, - onIssueDelete, -}: { - project: ProjectResponse; - sprints: SprintRecord[]; - issueData: IssueResponse; - members: UserRecord[]; - statuses: Record; - close: () => void; - onIssueUpdate?: () => void; - onIssueDelete?: (issueId: number) => void | Promise; -}) { +export function IssueDetailPane() { const { user } = useSession(); - const [assigneeIds, setAssigneeIds] = useState(assigneesToStringArray(issueData.Assignees)); - const [sprintId, setSprintId] = useState(issueData.Issue.sprintId?.toString() ?? "unassigned"); - const [status, setStatus] = useState(issueData.Issue.status); + 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(issueData.Issue.title); - const [originalTitle, setOriginalTitle] = useState(issueData.Issue.title); + const [title, setTitle] = useState(""); + const [originalTitle, setOriginalTitle] = useState(""); const [isSavingTitle, setIsSavingTitle] = useState(false); - const [description, setDescription] = useState(issueData.Issue.description); - const [originalDescription, setOriginalDescription] = useState(issueData.Issue.description); + const [description, setDescription] = useState(""); + const [originalDescription, setOriginalDescription] = useState(""); const [isEditingDescription, setIsEditingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false); const descriptionRef = useRef(null); useEffect(() => { + if (!issueData) return; setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setStatus(issueData.Issue.status); @@ -85,51 +89,51 @@ export function IssueDetailPane({ }; }, []); + if (!issueData || !selectedProject || !selectedOrganisation) { + return null; + } + 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" - ) : ( - 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" - ) : ( - s.id === newSprintId)} /> - )}{" "} - for {issueID(project.Project.key, issueData.Issue.number)} - , - { - dismissible: false, - }, - ); - }, - }); + 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[]) => { @@ -147,64 +151,58 @@ export function IssueDetailPane({ return; } - await issue.update({ - issueId: issueData.Issue.id, - assigneeIds: newAssigneeIdNumbers, - onSuccess: () => { - const assignedUsers = members.filter((m) => newAssigneeIdNumbers.includes(m.id)); - const displayText = - assignedUsers.length === 0 - ? "Unassigned" - : assignedUsers.length === 1 - ? assignedUsers[0].name - : `${assignedUsers.length} assignees`; - toast.success( -
- Updated assignees to {displayText} for{" "} - {issueID(project.Project.key, issueData.Issue.number)} -
, - { - dismissible: false, - }, - ); - onIssueUpdate?.(); - }, - onError: (error) => { - console.error("error updating assignees:", error); - setAssigneeIds(previousAssigneeIds); - - toast.error(`Error updating assignees: ${error}`, { + 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); - await issue.update({ - issueId: issueData.Issue.id, - status: value, - onSuccess: () => { - toast.success( - <> - {issueID(project.Project.key, issueData.Issue.number)}'s status updated to{" "} - - , - { dismissible: false }, - ); - onIssueUpdate?.(); - }, - onError: (error) => { - console.error("error updating status:", error); - setStatus(issueData.Issue.status); - - toast.error(`Error updating status: ${error}`, { - dismissible: false, - }); - }, - }); + 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 = () => { @@ -235,21 +233,19 @@ export function IssueDetailPane({ } setIsSavingTitle(true); - await issue.update({ - issueId: issueData.Issue.id, - title: trimmedTitle, - onSuccess: () => { - setOriginalTitle(trimmedTitle); - toast.success(`${issueID(project.Project.key, issueData.Issue.number)} Title updated`); - onIssueUpdate?.(); - setIsSavingTitle(false); - }, - onError: (error) => { - console.error("error updating title:", error); - setTitle(originalTitle); - setIsSavingTitle(false); - }, - }); + 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 () => { @@ -262,52 +258,50 @@ export function IssueDetailPane({ } setIsSavingDescription(true); - await issue.update({ - issueId: issueData.Issue.id, - description: trimmedDescription, - onSuccess: () => { - setOriginalDescription(trimmedDescription); - setDescription(trimmedDescription); - toast.success(`${issueID(project.Project.key, issueData.Issue.number)} Description updated`); - onIssueUpdate?.(); - setIsSavingDescription(false); - if (trimmedDescription === "") { - setIsEditingDescription(false); - } - }, - onError: (error) => { - console.error("error updating description:", error); - setDescription(originalDescription); - setIsSavingDescription(false); - }, - }); + 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 () => { - 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)}`, { + 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, - }); - }, - 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); + }, + ); + } finally { + setDeleteOpen(false); + } }; return ( @@ -315,7 +309,7 @@ export function IssueDetailPane({

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

@@ -325,7 +319,7 @@ export function IssueDetailPane({ - + selectIssue(null)} title={"Close"}>
@@ -355,14 +349,14 @@ export function IssueDetailPane({
setTitle(e.target.value)} + onChange={(event) => setTitle(event.target.value)} onBlur={handleTitleSave} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.currentTarget.blur(); - } else if (e.key === "Escape") { + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } else if (event.key === "Escape") { setTitle(originalTitle); - e.currentTarget.blur(); + event.currentTarget.blur(); } }} disabled={isSavingTitle} @@ -378,15 +372,15 @@ export function IssueDetailPane({