mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(kanban): drag-to-delete trash zone + bulk delete for task cards
Salvages #28125 by @Jpalmer95. Adds: - Drag-to-delete trash zone in the kanban dashboard - Bulk delete endpoint with cascading delete_task cleanup - Frontend updates (drag visual + drop handler) - Confirmation prompt before delete Resolved end-of-file test conflict by appending both halves.
This commit is contained in:
parent
e3823657d6
commit
dfcf48b476
6 changed files with 266 additions and 14 deletions
|
|
@ -3368,6 +3368,29 @@ def delete_archived_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
|||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def delete_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
"""Hard-delete a task and cascade to all related rows.
|
||||
|
||||
Because the schema does not use ``ON DELETE CASCADE`` foreign keys,
|
||||
we explicitly delete from child tables first, then the task row.
|
||||
This keeps the operation atomic (single ``write_txn``).
|
||||
|
||||
Returns ``True`` if the task existed and was deleted, ``False``
|
||||
if the task was not found.
|
||||
"""
|
||||
with write_txn(conn):
|
||||
cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
||||
if cur.rowcount != 1:
|
||||
return False
|
||||
conn.execute("DELETE FROM task_links WHERE parent_id = ? OR child_id = ?", (task_id, task_id))
|
||||
conn.execute("DELETE FROM task_comments WHERE task_id = ?", (task_id,))
|
||||
conn.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
|
||||
conn.execute("DELETE FROM task_runs WHERE task_id = ?", (task_id,))
|
||||
conn.execute("DELETE FROM kanban_notify_subs WHERE task_id = ?", (task_id,))
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
144
plugins/kanban/dashboard/dist/index.js
vendored
144
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -83,6 +83,12 @@
|
|||
completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids",
|
||||
suspected_hallucinated_references: "⚠ Prose referenced phantom card ids",
|
||||
};
|
||||
const FALLBACK_TRASH = {
|
||||
label: "Trash",
|
||||
title: "Drag a card here to permanently delete it",
|
||||
confirm: "Permanently delete this task? This cannot be undone.",
|
||||
dropHint: "Drop to delete",
|
||||
};
|
||||
const DIAGNOSTIC_EVENT_KIND_KEYS = {
|
||||
completion_blocked_hallucination: "completionBlockedHallucination",
|
||||
suspected_hallucinated_references: "suspectedHallucinatedReferences",
|
||||
|
|
@ -331,10 +337,12 @@
|
|||
const under = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||
proxy.style.display = "";
|
||||
const col = under && under.closest && under.closest("[data-kanban-column]");
|
||||
if (col !== lastTarget) {
|
||||
const trash = under && under.closest && under.closest("[data-kanban-trash]");
|
||||
const target = col || trash;
|
||||
if (target !== lastTarget) {
|
||||
if (lastTarget) lastTarget.classList.remove("hermes-kanban-column--drop");
|
||||
if (col) col.classList.add("hermes-kanban-column--drop");
|
||||
lastTarget = col;
|
||||
if (target) target.classList.add("hermes-kanban-column--drop");
|
||||
lastTarget = target;
|
||||
}
|
||||
}
|
||||
function up() {
|
||||
|
|
@ -344,10 +352,18 @@
|
|||
if (lastTarget) {
|
||||
lastTarget.classList.remove("hermes-kanban-column--drop");
|
||||
const status = lastTarget.getAttribute("data-kanban-column");
|
||||
lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", {
|
||||
detail: { taskId, status },
|
||||
bubbles: true,
|
||||
}));
|
||||
const isTrash = lastTarget.hasAttribute("data-kanban-trash");
|
||||
if (isTrash) {
|
||||
lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:delete", {
|
||||
detail: { taskId },
|
||||
bubbles: true,
|
||||
}));
|
||||
} else if (status) {
|
||||
lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", {
|
||||
detail: { taskId, status },
|
||||
bubbles: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
proxy.remove();
|
||||
}
|
||||
|
|
@ -878,6 +894,32 @@
|
|||
});
|
||||
}, [board, loadBoardList, switchBoard]);
|
||||
|
||||
const deleteTask = useCallback(function (taskId) {
|
||||
if (!window.confirm(tx(t, "trash.confirm", FALLBACK_TRASH.confirm))) return Promise.resolve();
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: "DELETE",
|
||||
}).then(function () {
|
||||
loadBoard();
|
||||
setSelectedIds(function (prev) {
|
||||
const next = new Set(prev);
|
||||
next.delete(taskId);
|
||||
return next;
|
||||
});
|
||||
}).catch(function (e) { setError(String(e.message || e)); });
|
||||
}, [board, loadBoard, t]);
|
||||
|
||||
const deleteSelected = useCallback(function (count) {
|
||||
if (selectedIds.size === 0) return Promise.resolve();
|
||||
if (!window.confirm(tx(t, "trash.confirmMany", "Permanently delete {n} selected tasks? This cannot be undone.", { n: count }))) return Promise.resolve();
|
||||
const ids = Array.from(selectedIds);
|
||||
setSelectedIds(new Set());
|
||||
return Promise.all(ids.map(function (id) {
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
})).then(function () {
|
||||
loadBoard();
|
||||
}).catch(function (e) { setError(String(e.message || e)); });
|
||||
}, [selectedIds, board, loadBoard, t]);
|
||||
|
||||
// --- render -------------------------------------------------------------
|
||||
if (loading && !boardData) {
|
||||
return h("div", { className: "p-8 text-sm text-muted-foreground" },
|
||||
|
|
@ -932,13 +974,14 @@
|
|||
},
|
||||
onRefresh: loadBoard,
|
||||
}),
|
||||
selectedIds.size > 0 ? h(BulkActionBar, {
|
||||
count: selectedIds.size,
|
||||
assignees: (boardData && boardData.assignees) || [],
|
||||
onApply: applyBulk,
|
||||
onClear: clearSelected,
|
||||
onSelectAllVisible: selectAllVisible,
|
||||
}) : null,
|
||||
selectedIds.size > 0 ? h(BulkActionBar, {
|
||||
count: selectedIds.size,
|
||||
assignees: (boardData && boardData.assignees) || [],
|
||||
onApply: applyBulk,
|
||||
onClear: clearSelected,
|
||||
onSelectAllVisible: selectAllVisible,
|
||||
onDelete: deleteSelected,
|
||||
}) : null,
|
||||
error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null,
|
||||
h(BoardColumns, {
|
||||
board: filteredBoard,
|
||||
|
|
@ -953,6 +996,7 @@
|
|||
selectAllInColumn,
|
||||
onMove: moveTask,
|
||||
onMoveSelected: moveSelected,
|
||||
onDelete: deleteTask,
|
||||
onOpen: setSelectedTaskId,
|
||||
onCreate: createTask,
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
|
|
@ -2009,6 +2053,14 @@
|
|||
size: "sm",
|
||||
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
|
||||
}, tx(t, "archive", "Archive")),
|
||||
h(Button, {
|
||||
onClick: function () {
|
||||
props.onDelete(props.count);
|
||||
},
|
||||
size: "sm",
|
||||
variant: "destructive",
|
||||
title: "Permanently delete selected tasks. This cannot be undone.",
|
||||
}, tx(t, "delete", "Delete")),
|
||||
h("div", { className: "hermes-kanban-bulk-priority",
|
||||
title: "Set priority on selected tasks. Higher = claimed first." },
|
||||
h(Input, {
|
||||
|
|
@ -2073,6 +2125,65 @@
|
|||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Trash Drop Zone
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function TrashDropZone(props) {
|
||||
const { t } = useI18n();
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const zoneRef = useRef(null);
|
||||
|
||||
useEffect(function () {
|
||||
if (!zoneRef.current) return undefined;
|
||||
const el = zoneRef.current;
|
||||
function onTouchDelete(e) {
|
||||
const taskId = e.detail && e.detail.taskId;
|
||||
if (taskId && props.onDelete) props.onDelete(taskId);
|
||||
}
|
||||
el.addEventListener("hermes-kanban:delete", onTouchDelete);
|
||||
return function () { el.removeEventListener("hermes-kanban:delete", onTouchDelete); };
|
||||
}, [props.onDelete]);
|
||||
|
||||
const handleDragOver = function (e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!dragOver) setDragOver(true);
|
||||
};
|
||||
const handleDragLeave = function () { setDragOver(false); };
|
||||
const handleDrop = function (e) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const taskId = e.dataTransfer.getData(MIME_TASK);
|
||||
if (!taskId) return;
|
||||
if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1) {
|
||||
if (window.confirm(tx(t, "trash.confirmMany", "Permanently delete {n} selected tasks? This cannot be undone.", { n: props.selectedIds.size }))) {
|
||||
const ids = Array.from(props.selectedIds);
|
||||
Promise.all(ids.map(function (id) { return props.onDelete(id); })).catch(function () {});
|
||||
}
|
||||
} else {
|
||||
props.onDelete(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
return h("div", {
|
||||
ref: zoneRef,
|
||||
"data-kanban-trash": "true",
|
||||
className: cn(
|
||||
"hermes-kanban-trash",
|
||||
dragOver ? "hermes-kanban-trash--drop" : "",
|
||||
props.draggingTaskId ? "hermes-kanban-trash--active" : "",
|
||||
),
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
h("span", { className: "hermes-kanban-trash-icon" }, "🗑️"),
|
||||
h("span", { className: "hermes-kanban-trash-label" },
|
||||
tx(t, "trash.dropHint", FALLBACK_TRASH.dropHint)),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Columns
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -2106,6 +2217,11 @@
|
|||
allTasks: props.allTasks,
|
||||
});
|
||||
}),
|
||||
h(TrashDropZone, {
|
||||
draggingTaskId: props.draggingTaskId,
|
||||
selectedIds: props.selectedIds,
|
||||
onDelete: props.onDelete,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
41
plugins/kanban/dashboard/dist/style.css
vendored
41
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -1498,3 +1498,44 @@
|
|||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Trash drop zone ------------------------------------------------- */
|
||||
|
||||
.hermes-kanban-trash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--color-card) 85%, transparent);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
min-height: 80px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 120ms ease, border-color 120ms ease, background-color 120ms ease;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hermes-kanban-trash--active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hermes-kanban-trash--drop {
|
||||
border-color: var(--color-destructive, #d14a4a);
|
||||
background: color-mix(in srgb, var(--color-destructive, #d14a4a) 8%, var(--color-card));
|
||||
color: var(--color-destructive, #d14a4a);
|
||||
}
|
||||
|
||||
.hermes-kanban-trash-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hermes-kanban-trash-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -730,6 +730,23 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /tasks/:id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
def delete_task(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
ok = kanban_db.delete_task(conn, task_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return {"deleted": True, "task_id": task_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _set_status_direct(
|
||||
conn: sqlite3.Connection, task_id: str, new_status: str,
|
||||
) -> bool:
|
||||
|
|
|
|||
|
|
@ -816,6 +816,34 @@ def test_list_tasks_order_by(kanban_home):
|
|||
except ValueError as e:
|
||||
assert "order_by must be one of" in str(e)
|
||||
|
||||
def test_delete_task_removes_task_and_cascades(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="to-delete", assignee="alice")
|
||||
kb.add_comment(conn, t, "user", "comment")
|
||||
kb.add_comment(conn, t, "user", "another")
|
||||
assert kb.delete_task(conn, t)
|
||||
assert kb.get_task(conn, t) is None
|
||||
assert len(kb.list_comments(conn, t)) == 0
|
||||
assert len(kb.list_events(conn, t)) == 0
|
||||
assert len(kb.list_runs(conn, t)) == 0
|
||||
|
||||
|
||||
def test_delete_task_returns_false_for_missing_task(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
assert not kb.delete_task(conn, "t_nonexistent")
|
||||
|
||||
|
||||
def test_delete_task_cascades_links(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
p = kb.create_task(conn, title="parent")
|
||||
c = kb.create_task(conn, title="child", parents=[p])
|
||||
child = kb.get_task(conn, c)
|
||||
assert child is not None and child.status == "todo"
|
||||
kb.delete_task(conn, p)
|
||||
assert kb.get_task(conn, p) is None
|
||||
child_after = kb.get_task(conn, c)
|
||||
assert child_after is not None and child_after.status == "ready"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comments / events / worker context
|
||||
|
|
|
|||
|
|
@ -485,6 +485,33 @@ def test_patch_status_running_rejected(client):
|
|||
assert statuses.get(t["id"]) != "running"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /tasks/:id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_task(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "to-delete"}).json()["task"]
|
||||
r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["deleted"] is True
|
||||
assert r.json()["task_id"] == t["id"]
|
||||
|
||||
# Gone from board
|
||||
board = client.get("/api/plugins/kanban/board").json()
|
||||
all_ids = [tt["id"] for col in board["columns"] for tt in col["tasks"]]
|
||||
assert t["id"] not in all_ids
|
||||
|
||||
# Gone from detail
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{t['id']}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete_task_not_found(client):
|
||||
r = client.delete("/api/plugins/kanban/tasks/t_nonexistent")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comments + Links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue