feat(KB-033): add inline card editing with optimistic updates
- Add inline edit mode to TaskCard (double-click to edit title/details) - Implement optimistic updates with automatic rollback on failure in useTasks hook - Wire onUpdateTask through Board, Column, and WorktreeGroup component hierarchy - Add CSS styling for inline edit mode with focus states and transitions - Add comprehensive test coverage for TaskCard editing and useTasks updates - Include changeset for patch release and update dashboard README
This commit is contained in:
5
.changeset/inline-card-editing.md
Normal file
5
.changeset/inline-card-editing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kb/dashboard": patch
|
||||
---
|
||||
|
||||
Add inline editing for cards in triage and todo columns. Double-click a card or use the edit button to quickly modify title and description without opening the full detail modal.
|
||||
@@ -6,6 +6,7 @@ Web-based dashboard for managing kb tasks. Provides a visual kanban board, list
|
||||
|
||||
### Task Management
|
||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
||||
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
|
||||
- **List View**: Alternative tabular view for tasks with sorting and filtering
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
|
||||
@@ -36,7 +36,7 @@ function AppInner() {
|
||||
return "board";
|
||||
});
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -160,6 +160,7 @@ function AppInner() {
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
|
||||
@@ -16,9 +16,13 @@ interface BoardProps {
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: BoardProps) {
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
@@ -43,6 +47,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,13 @@ interface ColumnProps {
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
@@ -110,6 +114,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
@@ -118,7 +123,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} onUpdateTask={onUpdateTask} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link, Clock, Layers, GitPullRequest } from "lucide-react";
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { Link, Clock, Layers, GitPullRequest, Pencil } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column } from "@kb/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -20,6 +20,8 @@ const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
done: "var(--done)",
|
||||
};
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
|
||||
interface TaskCardProps {
|
||||
@@ -29,11 +31,44 @@ interface TaskCardProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
tasks?: Task[]; // All tasks for dependency lookup
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, tasks = [] }: TaskCardProps) {
|
||||
export function TaskCard({
|
||||
task,
|
||||
queued,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title || "");
|
||||
const [editDescription, setEditDescription] = useState(task.description || "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.id, task.title, task.description]);
|
||||
|
||||
// Auto-focus on title when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
titleInputRef.current?.focus();
|
||||
titleInputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
@@ -82,13 +117,14 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, t
|
||||
}, [task.id, isFileDrag, addToast]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isEditing) return; // Don't open detail when editing
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast]);
|
||||
}, [task.id, onOpenDetail, addToast, isEditing]);
|
||||
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
@@ -103,8 +139,155 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, t
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDraggable = !queued && !isPaused;
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}`;
|
||||
const isDraggable = !queued && !isPaused && !isEditing; // Disable drag during edit
|
||||
|
||||
// Check if this card can be edited inline
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||
|
||||
const enterEditMode = useCallback((e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (!canEdit || isSaving) return;
|
||||
setIsEditing(true);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [canEdit, isSaving, task.title, task.description]);
|
||||
|
||||
const exitEditMode = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.title, task.description]);
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
return editTitle !== (task.title || "") || editDescription !== (task.description || "");
|
||||
}, [editTitle, editDescription, task.title, task.description]);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (!onUpdateTask || isSaving) return;
|
||||
if (!hasChanges()) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdateTask(task.id, {
|
||||
title: editTitle.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
addToast(`Updated ${task.id}`, "success");
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update ${task.id}: ${err.message}`, "error");
|
||||
// Stay in edit mode on error so user can retry
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onUpdateTask, task.id, editTitle, editDescription, isSaving, hasChanges, exitEditMode, addToast]);
|
||||
|
||||
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// Move focus to description textarea
|
||||
descTextareaRef.current?.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [exitEditMode]);
|
||||
|
||||
const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveChanges();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [saveChanges, exitEditMode]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Small delay to allow focus to move between title and description inputs
|
||||
// before checking if we should save or cancel
|
||||
setTimeout(() => {
|
||||
const activeElement = document.activeElement;
|
||||
const isFocusInEditArea =
|
||||
activeElement === titleInputRef.current ||
|
||||
activeElement === descTextareaRef.current ||
|
||||
activeElement?.closest(".card-editing-content");
|
||||
|
||||
if (!isFocusInEditArea) {
|
||||
if (hasChanges()) {
|
||||
saveChanges();
|
||||
} else {
|
||||
exitEditMode();
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, [hasChanges, saveChanges, exitEditMode]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (canEdit) {
|
||||
e.stopPropagation();
|
||||
enterEditMode(e);
|
||||
}
|
||||
}, [canEdit, enterEditMode]);
|
||||
|
||||
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
enterEditMode(e);
|
||||
}, [enterEditMode]);
|
||||
|
||||
// Auto-resize textarea (similar to InlineCreateCard)
|
||||
const handleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
className={cardClass}
|
||||
data-id={task.id}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="card-editing-content">
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="card-edit-title-input"
|
||||
placeholder="Task title (optional)"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
className="card-edit-desc-textarea"
|
||||
placeholder="Task description"
|
||||
value={editDescription}
|
||||
onChange={handleDescChange}
|
||||
onKeyDown={handleDescKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSaving}
|
||||
rows={1}
|
||||
/>
|
||||
{isSaving && (
|
||||
<div className="card-edit-loading">
|
||||
<span className="card-edit-loading-spinner" />
|
||||
<span className="card-edit-loading-text">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -117,6 +300,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, t
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="card-header">
|
||||
<span className="card-id">{task.id}</span>
|
||||
@@ -173,6 +357,17 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, t
|
||||
{task.size}
|
||||
</span>
|
||||
)}
|
||||
{/* Edit button - visible on hover for editable cards */}
|
||||
{canEdit && (
|
||||
<button
|
||||
className="card-edit-btn"
|
||||
onClick={handleEditClick}
|
||||
title="Edit task"
|
||||
aria-label="Edit task"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
|
||||
@@ -11,6 +11,10 @@ interface WorktreeGroupProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
tasks?: Task[]; // All tasks for dependency lookup
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function WorktreeGroup({
|
||||
@@ -21,6 +25,7 @@ export function WorktreeGroup({
|
||||
addToast,
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="worktree-group">
|
||||
@@ -31,7 +36,7 @@ export function WorktreeGroup({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={tasks} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={tasks} onUpdateTask={onUpdateTask} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -42,6 +47,7 @@ export function WorktreeGroup({
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={tasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Column, Task, TaskDetail } from "@kb/core";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
|
||||
@@ -535,3 +536,495 @@ describe("TaskCard size badge", () => {
|
||||
expect(sizeBadge?.textContent).toBe("M");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for inline editing functionality in TaskCard.
|
||||
*/
|
||||
describe("TaskCard inline editing", () => {
|
||||
const noopToast = vi.fn();
|
||||
const noopUpdateTask = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper to make editable task (triage or todo column)
|
||||
function makeEditableTask(overrides: Partial<Task> = {}): Task {
|
||||
return makeTask({
|
||||
column: "triage",
|
||||
status: undefined,
|
||||
paused: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to make non-editable task (in-progress, in-review, done, or agent active)
|
||||
function makeNonEditableTask(overrides: Partial<Task> = {}): Task {
|
||||
return makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
paused: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("shows edit button on hover for editable cards", () => {
|
||||
const task = makeEditableTask({ column: "triage" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeDefined();
|
||||
expect(editBtn.classList.contains("card-edit-btn")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows edit button for todo column tasks", () => {
|
||||
const task = makeEditableTask({ column: "todo" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for in-progress column", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for in-review column", () => {
|
||||
const task = makeTask({
|
||||
column: "in-review",
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for done column", () => {
|
||||
const task = makeTask({
|
||||
column: "done",
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when agent is active", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when task is paused", () => {
|
||||
const task = makeEditableTask({ paused: true });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when onUpdateTask callback is not provided", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("enters edit mode on double-click for editable cards", () => {
|
||||
const task = makeEditableTask({ title: "Test Title", description: "Test Description" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
expect(card).toBeDefined();
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
// Should show editing UI
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i);
|
||||
|
||||
expect(titleInput).toBeDefined();
|
||||
expect(descTextarea).toBeDefined();
|
||||
expect((titleInput as HTMLInputElement).value).toBe("Test Title");
|
||||
expect((descTextarea as HTMLTextAreaElement).value).toBe("Test Description");
|
||||
});
|
||||
|
||||
it("enters edit mode when clicking edit button", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
expect(titleInput).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT enter edit mode on double-click for non-editable cards", () => {
|
||||
const task = makeNonEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
// Should NOT show editing UI
|
||||
const titleInput = screen.queryByPlaceholderText(/Task title/i);
|
||||
expect(titleInput).toBeNull();
|
||||
});
|
||||
|
||||
it("Escape key cancels edit mode", () => {
|
||||
const task = makeEditableTask({ title: "Original Title" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i) as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Changed Title" } });
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(titleInput, { key: "Escape" });
|
||||
|
||||
// Should exit edit mode without saving
|
||||
expect(screen.queryByPlaceholderText(/Task title/i)).toBeNull();
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blurring with no changes cancels edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const task = makeEditableTask({ title: "Original Title", description: "Original Desc" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
await user.dblClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
|
||||
// Tab out to move focus outside the editing area
|
||||
await user.tab();
|
||||
await user.tab(); // Second tab to move past the textarea
|
||||
|
||||
// Wait for the blur handler to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should have exited edit mode
|
||||
expect(screen.queryByPlaceholderText(/Task title/i)).toBeNull();
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter in description saves changes", async () => {
|
||||
const task = makeEditableTask({ title: "Title", description: "Old Desc" });
|
||||
const mockUpdateTask = vi.fn().mockResolvedValue({ ...task, description: "New Desc" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Press Enter (not Shift+Enter)
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-099", {
|
||||
title: "Title",
|
||||
description: "New Desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Enter in title moves focus to description", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i) as HTMLInputElement;
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
|
||||
// Press Enter in title - should move focus to description
|
||||
fireEvent.keyDown(titleInput, { key: "Enter" });
|
||||
|
||||
// Description should receive focus
|
||||
expect(document.activeElement).toBe(descTextarea);
|
||||
});
|
||||
|
||||
it("Shift+Enter in description adds newline", () => {
|
||||
const task = makeEditableTask({ description: "Line 1" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
|
||||
// Press Shift+Enter - should not trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: true });
|
||||
|
||||
// updateTask should not be called
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state during save", async () => {
|
||||
const task = makeEditableTask({ title: "Title" });
|
||||
// Create a promise that we can resolve manually
|
||||
let resolveUpdate: (value: Task) => void;
|
||||
const updatePromise = new Promise<Task>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
const mockUpdateTask = vi.fn().mockReturnValue(updatePromise);
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter" });
|
||||
|
||||
// Should show loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Saving/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Resolve the update
|
||||
resolveUpdate!({ ...task, description: "New Desc" });
|
||||
|
||||
// Wait for save to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Saving/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when save fails", async () => {
|
||||
const task = makeEditableTask({ title: "Title" });
|
||||
const mockUpdateTask = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={addToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to update"), "error");
|
||||
});
|
||||
|
||||
// Should stay in edit mode on error
|
||||
expect(screen.getByPlaceholderText(/Task description/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("prevents drag during edit mode", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]') as HTMLElement;
|
||||
fireEvent.doubleClick(card);
|
||||
|
||||
// Card should have editing class
|
||||
expect(card.classList.contains("card-editing")).toBe(true);
|
||||
});
|
||||
|
||||
it("card has card-editing class when in edit mode", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]') as HTMLElement;
|
||||
expect(card.classList.contains("card-editing")).toBe(false);
|
||||
|
||||
fireEvent.doubleClick(card);
|
||||
|
||||
const editingCard = document.querySelector(".card-editing");
|
||||
expect(editingCard).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,11 @@ vi.mock("../../api", () => ({
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
@@ -403,4 +405,162 @@ describe("useTasks", () => {
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask", () => {
|
||||
it("updates task optimistically and returns server response", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Old Title",
|
||||
description: "Old Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
let returnedTask: Task | undefined;
|
||||
await act(async () => {
|
||||
returnedTask = await result.current.updateTask("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
expect(returnedTask).toEqual(updatedTask);
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
});
|
||||
|
||||
it("rolls back on error and rethrows", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Original Title",
|
||||
description: "Original Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
mockUpdateTask.mockRejectedValueOnce(new Error("Update failed"));
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateTask("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
})
|
||||
).rejects.toThrow("Update failed");
|
||||
|
||||
// Should have rolled back to original
|
||||
expect(result.current.tasks[0].title).toBe("Original Title");
|
||||
expect(result.current.tasks[0].description).toBe("Original Description");
|
||||
});
|
||||
|
||||
it("supports updating only title", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Old Title",
|
||||
description: "Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "New Title",
|
||||
description: "Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { title: "New Title" });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("Description");
|
||||
});
|
||||
|
||||
it("supports updating only description", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Title",
|
||||
description: "Old Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Title",
|
||||
description: "New Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { description: "New Description" });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
});
|
||||
|
||||
it("supports updating dependencies", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
dependencies: ["KB-002"],
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
dependencies: ["KB-002", "KB-003"],
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { dependencies: ["KB-002", "KB-003"] });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].dependencies).toEqual(["KB-002", "KB-003"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,5 +118,39 @@ export function useTasks() {
|
||||
return api.retryTask(id);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask };
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
): Promise<Task> => {
|
||||
// Optimistic update: apply changes immediately
|
||||
const previousTask = tasksRef.current.find((t) => t.id === id);
|
||||
const optimisticTask = previousTask
|
||||
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
|
||||
: undefined;
|
||||
|
||||
if (optimisticTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? optimisticTask : t))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTask = await api.updateTask(id, updates);
|
||||
// Replace with server response
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
return updatedTask;
|
||||
} catch (err) {
|
||||
// Rollback on error: restore previous state
|
||||
if (previousTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? previousTask : t))
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask };
|
||||
}
|
||||
|
||||
@@ -1432,6 +1432,159 @@ body {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* === Card Inline Editing === */
|
||||
|
||||
/* Card in edit mode - highlighted border matching column theme */
|
||||
.card.card-editing {
|
||||
border: 1px solid var(--todo);
|
||||
border-left: 3px solid var(--todo);
|
||||
background: var(--card);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card.card-editing[data-column="triage"] {
|
||||
border-color: var(--triage);
|
||||
border-left-color: var(--triage);
|
||||
}
|
||||
|
||||
.card.card-editing[data-column="todo"] {
|
||||
border-color: var(--todo);
|
||||
border-left-color: var(--todo);
|
||||
}
|
||||
|
||||
/* Edit content container */
|
||||
.card-editing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Title input in edit mode */
|
||||
.card-edit-title-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-edit-title-input:focus {
|
||||
border-color: var(--todo);
|
||||
border-bottom-color: var(--todo);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.card-edit-title-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-edit-title-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Description textarea in edit mode - follows InlineCreateCard patterns */
|
||||
.card-edit-desc-textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
min-height: 38px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-edit-desc-textarea:focus {
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.card-edit-desc-textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.card-edit-desc-textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Edit button - visible on hover */
|
||||
.card-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover .card-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-edit-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-edit-btn:focus {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--todo);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Loading state during save */
|
||||
.card-edit-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-edit-loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--todo);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.card-edit-loading-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Card saving state */
|
||||
.card.card-saving {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* === Inline Create Card === */
|
||||
.inline-create-card {
|
||||
background: var(--card);
|
||||
|
||||
Reference in New Issue
Block a user