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({
|
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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user