mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
issue title + description editing
This commit is contained in:
@@ -8,9 +8,12 @@ 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 { Input } from "@/components/ui/input";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { issue } from "@/lib/server";
|
||||
import { issueID } from "@/lib/utils";
|
||||
import SmallSprintDisplay from "./small-sprint-display";
|
||||
@@ -53,11 +56,26 @@ export function IssueDetailPane({
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
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(() => {
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
setAssigneeIds(assigneesToStringArray(issueData.Assignees));
|
||||
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(() => {
|
||||
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 () => {
|
||||
await issue.delete({
|
||||
issueId: issueData.Issue.id,
|
||||
@@ -279,11 +353,54 @@ export function IssueDetailPane({
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
{issueData.Issue.description !== "" && (
|
||||
<p className="text-sm">{issueData.Issue.description}</p>
|
||||
{description || isEditingDescription ? (
|
||||
<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">
|
||||
|
||||
25
packages/frontend/src/components/ui/textarea.tsx
Normal file
25
packages/frontend/src/components/ui/textarea.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user