mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(teams): add multi-select checkboxes and bulk delete to task list
Add checkbox column to task list view for selecting terminal-status tasks (completed/failed/cancelled). Header checkbox supports select-all with indeterminate state. Bulk action bar appears on selection with delete button that opens ConfirmDeleteDialog requiring user to type "delete" to confirm. Backend: new teams.tasks.delete-bulk RPC method with DeleteTasks batch SQL (DELETE ... WHERE id = ANY($1) RETURNING id). Broadcasts delete event per task for real-time UI sync. i18n: added bulk action keys for en/vi/zh.
This commit is contained in:
1 parent
aeadb20ba7
commit
ae3e5cebcf
12 files changed
+240
-11
No files matched your search
@@ -53,6 +53,7 @@ func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodTeamsTaskEvents, m.handleTaskEvents)
|
||||
router.Register(protocol.MethodTeamsTaskCreate, m.handleTaskCreate)
|
||||
router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete)
|
||||
router.Register(protocol.MethodTeamsTaskDeleteBulk, m.handleTaskDeleteBulk)
|
||||
router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign)
|
||||
}
|
||||
|
||||
@@ -651,6 +652,70 @@ func (m *TeamsMethods) handleTaskDelete(ctx context.Context, client *gateway.Cli
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Delete Bulk (hard-delete multiple terminal-status tasks) ---
|
||||
|
||||
type taskDeleteBulkParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskIDs []string `json:"taskIds"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskDeleteBulk(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskDeleteBulkParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
if len(params.TaskIDs) == 0 {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "taskIds is required"))
|
||||
return
|
||||
}
|
||||
|
||||
taskUUIDs := make([]uuid.UUID, 0, len(params.TaskIDs))
|
||||
for _, raw := range params.TaskIDs {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
continue // skip invalid IDs
|
||||
}
|
||||
taskUUIDs = append(taskUUIDs, id)
|
||||
}
|
||||
if len(taskUUIDs) == 0 {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no valid taskIds"))
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := m.teamStore.DeleteTasks(ctx, taskUUIDs, teamID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.tasks.delete-bulk failed", "team_id", teamID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"deleted": len(deleted),
|
||||
}))
|
||||
|
||||
// Broadcast delete event per task for real-time UI sync.
|
||||
if m.msgBus != nil {
|
||||
for _, id := range deleted {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDeleted, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: id.String(),
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskToAgent publishes a teammate-style inbound message so the
|
||||
// gateway consumer picks it up and runs the assigned agent, then auto-completes
|
||||
// the task on success or auto-fails on error.
|
||||
|
||||
@@ -261,6 +261,30 @@ func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`DELETE FROM team_tasks
|
||||
WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')
|
||||
RETURNING id`,
|
||||
taskIDs, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var deleted []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
return deleted, rows.Err()
|
||||
}
|
||||
|
||||
func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
|
||||
var tasks []store.TeamTaskData
|
||||
for rows.Next() {
|
||||
|
||||
@@ -327,6 +327,9 @@ type TeamStore interface {
|
||||
// DeleteTask permanently removes a terminal-status task (completed/failed/cancelled).
|
||||
// Returns ErrTaskNotFound if the task does not exist or is not in a terminal status.
|
||||
DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error
|
||||
// DeleteTasks permanently removes multiple terminal-status tasks in a single query.
|
||||
// Returns the list of IDs that were actually deleted.
|
||||
DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error)
|
||||
|
||||
// ClaimTask atomically transitions a task from pending to in_progress.
|
||||
// Only one agent can claim a given task (row-level lock, race-safe).
|
||||
|
||||
@@ -114,8 +114,9 @@ const (
|
||||
MethodTeamsTaskComments = "teams.tasks.comments"
|
||||
MethodTeamsTaskEvents = "teams.tasks.events"
|
||||
MethodTeamsTaskCreate = "teams.tasks.create"
|
||||
MethodTeamsTaskDelete = "teams.tasks.delete"
|
||||
MethodTeamsTaskAssign = "teams.tasks.assign"
|
||||
MethodTeamsTaskDelete = "teams.tasks.delete"
|
||||
MethodTeamsTaskDeleteBulk = "teams.tasks.delete-bulk"
|
||||
MethodTeamsTaskAssign = "teams.tasks.assign"
|
||||
MethodTeamsMembersAdd = "teams.members.add"
|
||||
MethodTeamsMembersRemove = "teams.members.remove"
|
||||
MethodTeamsUpdate = "teams.update"
|
||||
|
||||
@@ -140,6 +140,7 @@ export const Methods = {
|
||||
TEAMS_TASK_EVENTS: "teams.tasks.events",
|
||||
TEAMS_TASK_CREATE: "teams.tasks.create",
|
||||
TEAMS_TASK_DELETE: "teams.tasks.delete",
|
||||
TEAMS_TASK_DELETE_BULK: "teams.tasks.delete-bulk",
|
||||
TEAMS_TASK_ASSIGN: "teams.tasks.assign",
|
||||
TEAMS_MEMBERS_ADD: "teams.members.add",
|
||||
TEAMS_MEMBERS_REMOVE: "teams.members.remove",
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
},
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this task? This cannot be undone.",
|
||||
"selected": "{{count}} selected",
|
||||
"deleteSelected": "Delete selected",
|
||||
"deleteBulkTitle": "Delete tasks",
|
||||
"deleteBulkConfirm": "This will permanently delete {{count}} task(s). Type \"delete\" to confirm.",
|
||||
"badges": {
|
||||
"awaitingReply": "awaiting reply"
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
},
|
||||
"delete": "Xoá",
|
||||
"deleteConfirm": "Xoá task này? Không thể hoàn tác.",
|
||||
"selected": "Đã chọn {{count}}",
|
||||
"deleteSelected": "Xoá đã chọn",
|
||||
"deleteBulkTitle": "Xoá nhiều task",
|
||||
"deleteBulkConfirm": "Thao tác này sẽ xoá vĩnh viễn {{count}} task. Nhập \"delete\" để xác nhận.",
|
||||
"badges": {
|
||||
"awaitingReply": "chờ trả lời"
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
},
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "删除此任务?此操作无法撤消。",
|
||||
"selected": "已选择 {{count}} 项",
|
||||
"deleteSelected": "删除所选",
|
||||
"deleteBulkTitle": "批量删除任务",
|
||||
"deleteBulkConfirm": "此操作将永久删除 {{count}} 个任务。输入「delete」确认。",
|
||||
"badges": {
|
||||
"awaitingReply": "等待回复"
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ interface BoardContainerProps {
|
||||
getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
|
||||
getTaskDetail: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[] }>;
|
||||
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
|
||||
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
|
||||
onWorkspace?: () => void;
|
||||
}
|
||||
|
||||
export const BoardContainer = memo(function BoardContainer({
|
||||
teamId, members, scopes, isTeamV2,
|
||||
getTeamTasks, getTaskDetail, deleteTask, onWorkspace,
|
||||
getTeamTasks, getTaskDetail, deleteTask, deleteTasksBulk, onWorkspace,
|
||||
}: BoardContainerProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const viewMode = useBoardStore((s) => s.viewMode);
|
||||
@@ -164,6 +165,7 @@ export const BoardContainer = memo(function BoardContainer({
|
||||
emojiLookup={emojiLookup}
|
||||
getTaskDetail={getTaskDetail}
|
||||
deleteTask={deleteTask}
|
||||
deleteTasksBulk={deleteTasksBulk}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -153,6 +153,14 @@ export function useTeams() {
|
||||
[ws],
|
||||
);
|
||||
|
||||
const deleteTasksBulk = useCallback(
|
||||
async (teamId: string, taskIds: string[]) => {
|
||||
const res = await ws.call<{ deleted: number }>(Methods.TEAMS_TASK_DELETE_BULK, { teamId, taskIds });
|
||||
return res.deleted;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const assignTask = useCallback(
|
||||
async (teamId: string, taskId: string, agentId: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId });
|
||||
@@ -195,7 +203,7 @@ export function useTeams() {
|
||||
return {
|
||||
teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents,
|
||||
createTask, deleteTask, assignTask,
|
||||
createTask, deleteTask, deleteTasksBulk, assignTask,
|
||||
addMember, removeMember, updateTeamSettings, getKnownUsers,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { ClipboardList, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils";
|
||||
@@ -20,17 +22,64 @@ interface TaskListProps {
|
||||
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
|
||||
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
|
||||
}
|
||||
|
||||
export function TaskList({
|
||||
tasks, loading, teamId, members, isTeamV2, emojiLookup,
|
||||
getTaskDetail, deleteTask,
|
||||
getTaskDetail, deleteTask, deleteTasksBulk,
|
||||
}: TaskListProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
|
||||
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
|
||||
|
||||
const terminalTaskIds = useMemo(
|
||||
() => tasks.filter((t) => isTerminalStatus(t.status)).map((t) => t.id),
|
||||
[tasks],
|
||||
);
|
||||
|
||||
// Clear selection when tasks change (e.g. after delete/refresh).
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const taskIdSet = new Set(tasks.map((t) => t.id));
|
||||
const next = new Set([...prev].filter((id) => taskIdSet.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const toggleSelect = useCallback((taskId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) next.delete(taskId);
|
||||
else next.add(taskId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.size === terminalTaskIds.length && terminalTaskIds.length > 0
|
||||
? new Set()
|
||||
: new Set(terminalTaskIds),
|
||||
);
|
||||
}, [terminalTaskIds]);
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (!deleteTasksBulk || selectedIds.size === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTasksBulk(teamId, [...selectedIds]);
|
||||
setSelectedIds(new Set());
|
||||
setConfirmOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleteTasksBulk, teamId, selectedIds]);
|
||||
|
||||
if (loading && tasks.length === 0) {
|
||||
return <div className="py-8 text-center text-sm text-muted-foreground">{t("tasks.loading")}</div>;
|
||||
}
|
||||
@@ -52,10 +101,46 @@ export function TaskList({
|
||||
deleteTask(teamId, taskId);
|
||||
};
|
||||
|
||||
const hasBulkDelete = !!deleteTasksBulk && terminalTaskIds.length > 0;
|
||||
const allSelected = terminalTaskIds.length > 0 && selectedIds.size === terminalTaskIds.length;
|
||||
const someSelected = selectedIds.size > 0 && !allSelected;
|
||||
|
||||
const gridCols = hasBulkDelete
|
||||
? "grid-cols-[36px_70px_1fr_90px_100px_60px_40px]"
|
||||
: "grid-cols-[70px_1fr_90px_100px_60px_40px]";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bulk action bar */}
|
||||
{hasBulkDelete && selectedIds.size > 0 && (
|
||||
<div className="mb-2 flex items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("tasks.selected", { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("tasks.deleteSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="grid min-w-[500px] grid-cols-[70px_1fr_90px_100px_60px_40px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<div className={`grid min-w-[500px] ${gridCols} items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground`}>
|
||||
{hasBulkDelete && (
|
||||
<label className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 cursor-pointer rounded border-muted-foreground/50 text-base accent-primary"
|
||||
checked={allSelected}
|
||||
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span>{t("tasks.columns.id")}</span>
|
||||
<span>{t("tasks.columns.subject")}</span>
|
||||
<span>{t("tasks.columns.status")}</span>
|
||||
@@ -65,12 +150,28 @@ export function TaskList({
|
||||
</div>
|
||||
{tasks.map((task) => {
|
||||
const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id);
|
||||
const isTerminal = isTerminalStatus(task.status);
|
||||
const isChecked = selectedIds.has(task.id);
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="grid min-w-[500px] cursor-pointer grid-cols-[70px_1fr_90px_100px_60px_40px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
|
||||
className={`grid min-w-[500px] cursor-pointer ${gridCols} items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30 ${isChecked ? "bg-destructive/5" : ""}`}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
>
|
||||
{hasBulkDelete && (
|
||||
<label className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
{isTerminal ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 cursor-pointer rounded border-muted-foreground/50 text-base accent-primary"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleSelect(task.id)}
|
||||
/>
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<span className="font-mono text-xs text-muted-foreground">{task.identifier || "\u2014"}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{task.subject}</p>
|
||||
@@ -80,7 +181,7 @@ export function TaskList({
|
||||
{task.task_type && task.task_type !== "general" && (
|
||||
<Badge variant="outline" className="mt-0.5 text-[10px]">{task.task_type}</Badge>
|
||||
)}
|
||||
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminalStatus(task.status) && (
|
||||
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${task.progress_percent}%` }} />
|
||||
@@ -103,7 +204,7 @@ export function TaskList({
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{task.priority}</span>
|
||||
<div>
|
||||
{deleteTask && isTerminalStatus(task.status) && (
|
||||
{deleteTask && isTerminal && (
|
||||
<button
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => handleDelete(e, task.id)}
|
||||
@@ -134,6 +235,17 @@ export function TaskList({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t("tasks.deleteBulkTitle")}
|
||||
description={t("tasks.deleteBulkConfirm", { count: selectedIds.size })}
|
||||
confirmValue="delete"
|
||||
confirmLabel={t("tasks.deleteSelected")}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const {
|
||||
getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam,
|
||||
getTaskDetail, deleteTask,
|
||||
getTaskDetail, deleteTask, deleteTasksBulk,
|
||||
} = useTeams();
|
||||
|
||||
const [team, setTeam] = useState<TeamData | null>(null);
|
||||
@@ -100,6 +100,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
getTeamTasks={getTeamTasks}
|
||||
getTaskDetail={getTaskDetail}
|
||||
deleteTask={deleteTask}
|
||||
deleteTasksBulk={deleteTasksBulk}
|
||||
onWorkspace={() => setWorkspaceOpen(true)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user