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:
@@ -124,27 +124,24 @@ function ElapsedTimer({ since }: { since: string }) {
|
||||
|
||||
function EditableText({
|
||||
value,
|
||||
onSave,
|
||||
onChange,
|
||||
multiline = false,
|
||||
className = "",
|
||||
placeholder = "Click to edit...",
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (val: string) => void;
|
||||
onChange: (val: string) => void;
|
||||
multiline?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const ref = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => { setDraft(value); }, [value]);
|
||||
useEffect(() => { if (editing) ref.current?.focus(); }, [editing]);
|
||||
|
||||
const save = () => {
|
||||
const stopEditing = () => {
|
||||
setEditing(false);
|
||||
if (draft.trim() !== value) onSave(draft.trim());
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
@@ -164,10 +161,10 @@ function EditableText({
|
||||
return (
|
||||
<textarea
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setDraft(value); setEditing(false); } }}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={stopEditing}
|
||||
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}`}
|
||||
rows={3}
|
||||
/>
|
||||
@@ -177,12 +174,12 @@ function EditableText({
|
||||
return (
|
||||
<input
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={save}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={stopEditing}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") { setDraft(value); setEditing(false); }
|
||||
if (e.key === "Enter") stopEditing();
|
||||
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}`}
|
||||
/>
|
||||
@@ -248,6 +245,53 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
const isActive = task.status === "active";
|
||||
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) => {
|
||||
if (!hasToken) return;
|
||||
setSaving(true);
|
||||
@@ -294,8 +338,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</div>
|
||||
{hasToken ? (
|
||||
<EditableText
|
||||
value={task.title}
|
||||
onSave={(val) => handleFieldSave("title", val)}
|
||||
value={draftTitle}
|
||||
onChange={setDraftTitle}
|
||||
className="text-lg font-bold text-gray-900 leading-snug"
|
||||
/>
|
||||
) : (
|
||||
@@ -326,9 +370,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
{allPriorities.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handleFieldSave("priority", p)}
|
||||
onClick={() => setDraftPriority(p)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
||||
p === task.priority
|
||||
p === draftPriority
|
||||
? priorityColors[p]
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
}`}
|
||||
@@ -350,9 +394,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
{allSources.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleFieldSave("source", s)}
|
||||
onClick={() => setDraftSource(s)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
||||
s === task.source
|
||||
s === draftSource
|
||||
? sourceColors[s] || sourceColors.other
|
||||
: "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>
|
||||
{hasToken ? (
|
||||
<EditableText
|
||||
value={task.description || ""}
|
||||
onSave={(val) => handleFieldSave("description", val)}
|
||||
value={draftDescription}
|
||||
onChange={setDraftDescription}
|
||||
multiline
|
||||
className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap"
|
||||
placeholder="Add a description..."
|
||||
@@ -461,6 +505,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</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 */}
|
||||
{hasToken && actions.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
|
||||
@@ -14,3 +14,18 @@
|
||||
.animate-slide-in-right {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user