feat: save/cancel buttons for task editing (HQ-19)

- EditableText now updates draft state instead of saving immediately
- Panel tracks dirty state across title, description, priority, source
- Save/Cancel bar slides up when any field is modified
- Cancel reverts all changes, Save commits them in one API call
- Slide-up animation for the save/cancel bar
This commit is contained in:
2026-01-29 01:49:31 +00:00
parent 93746f0f71
commit f1a314c60d
2 changed files with 105 additions and 23 deletions

View File

@@ -124,27 +124,24 @@ function ElapsedTimer({ since }: { since: string }) {
function EditableText({ function EditableText({
value, value,
onSave, onChange,
multiline = false, multiline = false,
className = "", className = "",
placeholder = "Click to edit...", placeholder = "Click to edit...",
}: { }: {
value: string; value: string;
onSave: (val: string) => void; onChange: (val: string) => void;
multiline?: boolean; multiline?: boolean;
className?: string; className?: string;
placeholder?: string; placeholder?: string;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const ref = useRef<HTMLInputElement | HTMLTextAreaElement>(null); const ref = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => { setDraft(value); }, [value]);
useEffect(() => { if (editing) ref.current?.focus(); }, [editing]); useEffect(() => { if (editing) ref.current?.focus(); }, [editing]);
const save = () => { const stopEditing = () => {
setEditing(false); setEditing(false);
if (draft.trim() !== value) onSave(draft.trim());
}; };
if (!editing) { if (!editing) {
@@ -164,10 +161,10 @@ function EditableText({
return ( return (
<textarea <textarea
ref={ref as React.RefObject<HTMLTextAreaElement>} ref={ref as React.RefObject<HTMLTextAreaElement>}
value={draft} value={value}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => onChange(e.target.value)}
onBlur={save} onBlur={stopEditing}
onKeyDown={(e) => { if (e.key === "Escape") { setDraft(value); setEditing(false); } }} onKeyDown={(e) => { if (e.key === "Escape") stopEditing(); }}
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 resize-y min-h-[60px] ${className}`} className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 resize-y min-h-[60px] ${className}`}
rows={3} rows={3}
/> />
@@ -177,12 +174,12 @@ function EditableText({
return ( return (
<input <input
ref={ref as React.RefObject<HTMLInputElement>} ref={ref as React.RefObject<HTMLInputElement>}
value={draft} value={value}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => onChange(e.target.value)}
onBlur={save} onBlur={stopEditing}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") save(); if (e.key === "Enter") stopEditing();
if (e.key === "Escape") { setDraft(value); setEditing(false); } if (e.key === "Escape") stopEditing();
}} }}
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${className}`} className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${className}`}
/> />
@@ -248,6 +245,53 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
const isActive = task.status === "active"; const isActive = task.status === "active";
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Draft state for editable fields
const [draftTitle, setDraftTitle] = useState(task.title);
const [draftDescription, setDraftDescription] = useState(task.description || "");
const [draftPriority, setDraftPriority] = useState(task.priority);
const [draftSource, setDraftSource] = useState(task.source);
// Reset drafts when task changes
useEffect(() => {
setDraftTitle(task.title);
setDraftDescription(task.description || "");
setDraftPriority(task.priority);
setDraftSource(task.source);
}, [task.id, task.title, task.description, task.priority, task.source]);
// Detect if any field has been modified
const isDirty =
draftTitle !== task.title ||
draftDescription !== (task.description || "") ||
draftPriority !== task.priority ||
draftSource !== task.source;
const handleCancel = () => {
setDraftTitle(task.title);
setDraftDescription(task.description || "");
setDraftPriority(task.priority);
setDraftSource(task.source);
};
const handleSave = async () => {
if (!hasToken || !isDirty) return;
setSaving(true);
try {
const updates: Record<string, string> = {};
if (draftTitle !== task.title) updates.title = draftTitle.trim();
if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim();
if (draftPriority !== task.priority) updates.priority = draftPriority;
if (draftSource !== task.source) updates.source = draftSource;
await updateTask(task.id, updates, token);
onTaskUpdated();
} catch (e) {
console.error("Failed to update:", e);
} finally {
setSaving(false);
}
};
// Legacy single-field save kept for non-draft fields if needed
const handleFieldSave = async (field: string, value: string) => { const handleFieldSave = async (field: string, value: string) => {
if (!hasToken) return; if (!hasToken) return;
setSaving(true); setSaving(true);
@@ -294,8 +338,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
{hasToken ? ( {hasToken ? (
<EditableText <EditableText
value={task.title} value={draftTitle}
onSave={(val) => handleFieldSave("title", val)} onChange={setDraftTitle}
className="text-lg font-bold text-gray-900 leading-snug" className="text-lg font-bold text-gray-900 leading-snug"
/> />
) : ( ) : (
@@ -326,9 +370,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{allPriorities.map((p) => ( {allPriorities.map((p) => (
<button <button
key={p} key={p}
onClick={() => handleFieldSave("priority", p)} onClick={() => setDraftPriority(p)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${ className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
p === task.priority p === draftPriority
? priorityColors[p] ? priorityColors[p]
: "bg-gray-100 text-gray-500 hover:bg-gray-200" : "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`} }`}
@@ -350,9 +394,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{allSources.map((s) => ( {allSources.map((s) => (
<button <button
key={s} key={s}
onClick={() => handleFieldSave("source", s)} onClick={() => setDraftSource(s)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${ className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
s === task.source s === draftSource
? sourceColors[s] || sourceColors.other ? sourceColors[s] || sourceColors.other
: "bg-gray-100 text-gray-500 hover:bg-gray-200" : "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`} }`}
@@ -375,8 +419,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
{hasToken ? ( {hasToken ? (
<EditableText <EditableText
value={task.description || ""} value={draftDescription}
onSave={(val) => handleFieldSave("description", val)} onChange={setDraftDescription}
multiline multiline
className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap" className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap"
placeholder="Add a description..." placeholder="Add a description..."
@@ -461,6 +505,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
</div> </div>
{/* Save / Cancel Bar */}
{hasToken && isDirty && (
<div className="px-6 py-3 border-t border-blue-200 bg-blue-50 flex items-center justify-between animate-slide-up">
<span className="text-sm text-blue-700 font-medium">Unsaved changes</span>
<div className="flex gap-2">
<button
onClick={handleCancel}
disabled={saving}
className="text-sm px-4 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 font-medium hover:bg-gray-50 transition disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="text-sm px-4 py-2 rounded-lg border border-blue-400 bg-blue-600 text-white font-medium hover:bg-blue-700 transition disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
)}
{/* Actions Footer */} {/* Actions Footer */}
{hasToken && actions.length > 0 && ( {hasToken && actions.length > 0 && (
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50"> <div className="px-6 py-4 border-t border-gray-200 bg-gray-50">

View File

@@ -14,3 +14,18 @@
.animate-slide-in-right { .animate-slide-in-right {
animation: slide-in-right 0.25s ease-out; animation: slide-in-right 0.25s ease-out;
} }
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}