issue title + description editing

This commit is contained in:
Oliver Bryan
2026-01-18 22:30:59 +00:00
parent 303541e656
commit 989e2afac3
2 changed files with 146 additions and 4 deletions

View File

@@ -8,9 +8,12 @@ import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { TimerDisplay } from "@/components/timer-display"; import { TimerDisplay } from "@/components/timer-display";
import { TimerModal } from "@/components/timer-modal"; import { TimerModal } from "@/components/timer-modal";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { issue } from "@/lib/server"; import { issue } from "@/lib/server";
import { issueID } from "@/lib/utils"; import { issueID } from "@/lib/utils";
import SmallSprintDisplay from "./small-sprint-display"; import SmallSprintDisplay from "./small-sprint-display";
@@ -53,11 +56,26 @@ export function IssueDetailPane({
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null); const copyTimeoutRef = useRef<number | null>(null);
const [title, setTitle] = useState(issueData.Issue.title);
const [originalTitle, setOriginalTitle] = useState(issueData.Issue.title);
const [isSavingTitle, setIsSavingTitle] = useState(false);
const [description, setDescription] = useState(issueData.Issue.description);
const [originalDescription, setOriginalDescription] = useState(issueData.Issue.description);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isSavingDescription, setIsSavingDescription] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setAssigneeIds(assigneesToStringArray(issueData.Assignees));
setStatus(issueData.Issue.status); setStatus(issueData.Issue.status);
}, [issueData.Issue.sprintId, issueData.Assignees, issueData.Issue.status]); setTitle(issueData.Issue.title);
setOriginalTitle(issueData.Issue.title);
setDescription(issueData.Issue.description);
setOriginalDescription(issueData.Issue.description);
setIsEditingDescription(false);
}, [issueData]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -209,6 +227,62 @@ export function IssueDetailPane({
} }
}; };
const handleTitleSave = async () => {
const trimmedTitle = title.trim();
if (trimmedTitle === "" || trimmedTitle === originalTitle) {
setTitle(originalTitle);
return;
}
setIsSavingTitle(true);
await issue.update({
issueId: issueData.Issue.id,
title: trimmedTitle,
onSuccess: () => {
setOriginalTitle(trimmedTitle);
toast.success("Title updated");
onIssueUpdate?.();
setIsSavingTitle(false);
},
onError: (error) => {
console.error("error updating title:", error);
setTitle(originalTitle);
setIsSavingTitle(false);
},
});
};
const handleDescriptionSave = async () => {
const trimmedDescription = description.trim();
if (trimmedDescription === originalDescription) {
if (trimmedDescription === "") {
setIsEditingDescription(false);
}
return;
}
setIsSavingDescription(true);
await issue.update({
issueId: issueData.Issue.id,
description: trimmedDescription,
onSuccess: () => {
setOriginalDescription(trimmedDescription);
setDescription(trimmedDescription);
toast.success("Description updated");
onIssueUpdate?.();
setIsSavingDescription(false);
if (trimmedDescription === "") {
setIsEditingDescription(false);
}
},
onError: (error) => {
console.error("error updating description:", error);
setDescription(originalDescription);
setIsSavingDescription(false);
},
});
};
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
await issue.delete({ await issue.delete({
issueId: issueData.Issue.id, issueId: issueData.Issue.id,
@@ -279,11 +353,54 @@ export function IssueDetailPane({
)} )}
/> />
<div className="flex w-full items-center min-w-0"> <div className="flex w-full items-center min-w-0">
<span className="block w-full truncate">{issueData.Issue.title}</span> <Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
} else if (e.key === "Escape") {
setTitle(originalTitle);
e.currentTarget.blur();
}
}}
disabled={isSavingTitle}
className="w-full border-transparent hover:border-input focus:border-input h-auto py-0.5"
/>
</div> </div>
</div> </div>
{issueData.Issue.description !== "" && ( {description || isEditingDescription ? (
<p className="text-sm">{issueData.Issue.description}</p> <Textarea
ref={descriptionRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={handleDescriptionSave}
onKeyDown={(e) => {
if (e.key === "Escape" || (e.ctrlKey && e.key === "Enter")) {
setDescription(originalDescription);
if (originalDescription === "") {
setIsEditingDescription(false);
}
e.currentTarget.blur();
}
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-transparent hover:border-input focus:border-input resize-none min-h-[60px]"
/>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground justify-start px-2"
onClick={() => {
setIsEditingDescription(true);
setTimeout(() => descriptionRef.current?.focus(), 0);
}}
>
Add description
</Button>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -0,0 +1,25 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input dark:bg-input/30 w-full min-w-0 border bg-transparent",
"transition-[color,box-shadow]",
"focus-visible:border-ring",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"field-sizing-content min-h-2 px-3 py-2 text-base md:text-sm resize-none",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"outline-none",
className,
)}
{...props}
/>
);
}
export { Textarea };