Skip to content

Commit aab7604

Browse files
author
Vibe Kanban
committed
feat: add bulk delete tasks by status
- Add bulk delete API endpoint for deleting tasks by status - Add BulkDeleteTasksDialog component with confirmation - Add trash icon to all kanban column headers (not just Done) - Dynamic tooltip showing column name (e.g., "Clear To Do tasks") - Add clearColumnName i18n key with interpolation support
1 parent 9aef10a commit aab7604

13 files changed

Lines changed: 372 additions & 8 deletions

File tree

crates/server/src/bin/generate_types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ fn generate_types_content() -> String {
120120
server::routes::tasks::ShareTaskResponse::decl(),
121121
server::routes::tasks::CreateAndStartTaskRequest::decl(),
122122
server::routes::task_attempts::pr::CreatePrApiRequest::decl(),
123+
server::routes::tasks::BulkDeleteTasksRequest::decl(),
124+
server::routes::tasks::BulkDeleteTasksResponse::decl(),
123125
server::routes::images::ImageResponse::decl(),
124126
server::routes::images::ImageMetadata::decl(),
125127
server::routes::task_attempts::CreateTaskAttemptBody::decl(),

crates/server/src/routes/tasks.rs

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use db::models::{
1616
image::TaskImage,
1717
project::{Project, ProjectError},
1818
repo::Repo,
19-
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
19+
task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask},
2020
workspace::{CreateWorkspace, Workspace},
2121
workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo},
2222
};
@@ -424,6 +424,171 @@ pub async fn delete_task(
424424
Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(()))))
425425
}
426426

427+
#[derive(Debug, Serialize, Deserialize, TS)]
428+
pub struct BulkDeleteTasksRequest {
429+
pub project_id: Uuid,
430+
pub status: TaskStatus,
431+
}
432+
433+
#[derive(Debug, Serialize, Deserialize, TS)]
434+
pub struct BulkDeleteTasksResponse {
435+
pub deleted_count: u64,
436+
}
437+
438+
pub async fn bulk_delete_tasks(
439+
State(deployment): State<DeploymentImpl>,
440+
Json(payload): Json<BulkDeleteTasksRequest>,
441+
) -> Result<(StatusCode, ResponseJson<ApiResponse<BulkDeleteTasksResponse>>), ApiError> {
442+
let pool = &deployment.db().pool;
443+
444+
// Fetch all tasks with the specified status for the project
445+
let all_tasks =
446+
Task::find_by_project_id_with_attempt_status(pool, payload.project_id).await?;
447+
448+
let tasks_to_delete: Vec<_> = all_tasks
449+
.into_iter()
450+
.filter(|t| t.status == payload.status)
451+
.collect();
452+
453+
if tasks_to_delete.is_empty() {
454+
return Ok((
455+
StatusCode::OK,
456+
ResponseJson(ApiResponse::success(BulkDeleteTasksResponse {
457+
deleted_count: 0,
458+
})),
459+
));
460+
}
461+
462+
// Check for any running processes
463+
for task in &tasks_to_delete {
464+
if deployment
465+
.container()
466+
.has_running_processes(task.id)
467+
.await?
468+
{
469+
return Err(ApiError::Conflict(format!(
470+
"Task '{}' has running execution processes. Please wait for them to complete or stop them first.",
471+
task.title
472+
)));
473+
}
474+
}
475+
476+
// Gather all data BEFORE starting the transaction to minimize lock time
477+
let mut all_workspace_dirs: Vec<PathBuf> = Vec::new();
478+
let mut all_repositories: Vec<Repo> = Vec::new();
479+
let mut task_attempts: Vec<(Uuid, Vec<Workspace>)> = Vec::new();
480+
let mut shared_task_ids: Vec<Uuid> = Vec::new();
481+
482+
for task in &tasks_to_delete {
483+
// Gather task attempts data
484+
let attempts = Workspace::fetch_all(pool, Some(task.id))
485+
.await
486+
.map_err(|e| {
487+
tracing::error!("Failed to fetch task attempts for task {}: {}", task.id, e);
488+
ApiError::Workspace(e)
489+
})?;
490+
491+
let repositories = WorkspaceRepo::find_unique_repos_for_task(pool, task.id).await?;
492+
all_repositories.extend(repositories);
493+
494+
// Collect workspace directories
495+
let workspace_dirs: Vec<PathBuf> = attempts
496+
.iter()
497+
.filter_map(|attempt| attempt.container_ref.as_ref().map(PathBuf::from))
498+
.collect();
499+
all_workspace_dirs.extend(workspace_dirs);
500+
501+
// Collect shared task IDs for deletion
502+
if let Some(shared_task_id) = task.shared_task_id {
503+
shared_task_ids.push(shared_task_id);
504+
}
505+
506+
task_attempts.push((task.id, attempts));
507+
}
508+
509+
// Handle shared task deletions (external API calls, not DB writes)
510+
if let Ok(publisher) = deployment.share_publisher() {
511+
for shared_task_id in &shared_task_ids {
512+
if let Err(e) = publisher.delete_shared_task(*shared_task_id).await {
513+
tracing::warn!("Failed to delete shared task {}: {}", shared_task_id, e);
514+
}
515+
}
516+
}
517+
518+
// Now do all DB writes in a single transaction
519+
let mut deleted_count = 0u64;
520+
let mut tx = pool.begin().await?;
521+
522+
for (task_id, attempts) in &task_attempts {
523+
// Nullify parent_workspace_id for child tasks
524+
for attempt in attempts {
525+
Task::nullify_children_by_workspace_id(&mut *tx, attempt.id).await?;
526+
}
527+
528+
// Delete the task
529+
let rows_affected = Task::delete(&mut *tx, *task_id).await?;
530+
deleted_count += rows_affected;
531+
}
532+
533+
tx.commit().await?;
534+
535+
deployment
536+
.track_if_analytics_allowed(
537+
"tasks_bulk_deleted",
538+
serde_json::json!({
539+
"project_id": payload.project_id.to_string(),
540+
"status": payload.status.to_string(),
541+
"deleted_count": deleted_count,
542+
}),
543+
)
544+
.await;
545+
546+
// Background cleanup
547+
let project_id = payload.project_id;
548+
let pool = pool.clone();
549+
tokio::spawn(async move {
550+
tracing::info!(
551+
"Starting background cleanup for {} deleted tasks in project {}",
552+
deleted_count,
553+
project_id
554+
);
555+
556+
for workspace_dir in &all_workspace_dirs {
557+
if let Err(e) =
558+
WorkspaceManager::cleanup_workspace(workspace_dir, &all_repositories).await
559+
{
560+
tracing::error!(
561+
"Background workspace cleanup failed at {}: {}",
562+
workspace_dir.display(),
563+
e
564+
);
565+
}
566+
}
567+
568+
match Repo::delete_orphaned(&pool).await {
569+
Ok(count) if count > 0 => {
570+
tracing::info!("Deleted {} orphaned repo records", count);
571+
}
572+
Err(e) => {
573+
tracing::error!("Failed to delete orphaned repos: {}", e);
574+
}
575+
_ => {}
576+
}
577+
578+
tracing::info!(
579+
"Background cleanup completed for bulk delete in project {}",
580+
project_id
581+
);
582+
});
583+
584+
Ok((
585+
StatusCode::ACCEPTED,
586+
ResponseJson(ApiResponse::success(BulkDeleteTasksResponse {
587+
deleted_count,
588+
})),
589+
))
590+
}
591+
427592
#[derive(Debug, Serialize, Deserialize, TS)]
428593
pub struct ShareTaskResponse {
429594
pub shared_task_id: Uuid,
@@ -471,6 +636,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
471636
.route("/", get(get_tasks).post(create_task))
472637
.route("/stream/ws", get(stream_tasks_ws))
473638
.route("/create-and-start", post(create_task_and_start))
639+
.route("/bulk-delete", post(bulk_delete_tasks))
474640
.nest("/{task_id}", task_id_router);
475641

476642
// mount under /projects/:project_id/tasks
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { useState } from 'react';
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog';
10+
import { Button } from '@/components/ui/button';
11+
import { Alert } from '@/components/ui/alert';
12+
import { tasksApi } from '@/lib/api';
13+
import type { TaskStatus } from 'shared/types';
14+
import NiceModal, { useModal } from '@ebay/nice-modal-react';
15+
import { defineModal } from '@/lib/modals';
16+
import { statusLabels } from '@/utils/statusLabels';
17+
18+
export interface BulkDeleteTasksDialogProps {
19+
projectId: string;
20+
status: TaskStatus;
21+
count: number;
22+
}
23+
24+
const BulkDeleteTasksDialogImpl =
25+
NiceModal.create<BulkDeleteTasksDialogProps>(
26+
({ projectId, status, count }) => {
27+
const modal = useModal();
28+
const [isDeleting, setIsDeleting] = useState(false);
29+
const [error, setError] = useState<string | null>(null);
30+
31+
const statusLabel = statusLabels[status];
32+
33+
const handleConfirmDelete = async () => {
34+
setIsDeleting(true);
35+
setError(null);
36+
37+
try {
38+
await tasksApi.bulkDelete({ project_id: projectId, status });
39+
modal.resolve();
40+
modal.hide();
41+
} catch (err: unknown) {
42+
const errorMessage =
43+
err instanceof Error ? err.message : 'Failed to delete tasks';
44+
setError(errorMessage);
45+
} finally {
46+
setIsDeleting(false);
47+
}
48+
};
49+
50+
const handleCancelDelete = () => {
51+
modal.reject();
52+
modal.hide();
53+
};
54+
55+
return (
56+
<Dialog
57+
open={modal.visible}
58+
onOpenChange={(open) => !open && handleCancelDelete()}
59+
>
60+
<DialogContent>
61+
<DialogHeader>
62+
<DialogTitle>Clear {statusLabel} Tasks</DialogTitle>
63+
<DialogDescription>
64+
Are you sure you want to delete{' '}
65+
<span className="font-semibold">
66+
{count} {count === 1 ? 'task' : 'tasks'}
67+
</span>{' '}
68+
from {statusLabel}?
69+
</DialogDescription>
70+
</DialogHeader>
71+
72+
<Alert variant="destructive" className="mb-4">
73+
<strong>Warning:</strong> This action will permanently delete all{' '}
74+
{statusLabel.toLowerCase()} tasks and cannot be undone.
75+
</Alert>
76+
77+
{error && (
78+
<Alert variant="destructive" className="mb-4">
79+
{error}
80+
</Alert>
81+
)}
82+
83+
<DialogFooter>
84+
<Button
85+
variant="outline"
86+
onClick={handleCancelDelete}
87+
disabled={isDeleting}
88+
autoFocus
89+
>
90+
Cancel
91+
</Button>
92+
<Button
93+
variant="destructive"
94+
onClick={handleConfirmDelete}
95+
disabled={isDeleting}
96+
>
97+
{isDeleting ? 'Deleting...' : `Delete ${count} Tasks`}
98+
</Button>
99+
</DialogFooter>
100+
</DialogContent>
101+
</Dialog>
102+
);
103+
}
104+
);
105+
106+
export const BulkDeleteTasksDialog = defineModal<
107+
BulkDeleteTasksDialogProps,
108+
void
109+
>(BulkDeleteTasksDialogImpl);

frontend/src/components/tasks/TaskKanbanBoard.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { memo } from 'react';
1+
import { memo, useCallback } from 'react';
2+
import { useQueryClient } from '@tanstack/react-query';
23
import { useAuth } from '@/hooks';
34
import {
45
type DragEndEvent,
@@ -12,6 +13,9 @@ import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
1213
import { statusBoardColors, statusLabels } from '@/utils/statusLabels';
1314
import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
1415
import { SharedTaskCard } from './SharedTaskCard';
16+
import { BulkDeleteTasksDialog } from '@/components/dialogs/tasks/BulkDeleteTasksDialog';
17+
import { taskKeys } from '@/hooks/useTask';
18+
import { taskRelationshipsKeys } from '@/hooks/useTaskRelationships';
1519

1620
export type KanbanColumnItem =
1721
| {
@@ -48,17 +52,44 @@ function TaskKanbanBoard({
4852
projectId,
4953
}: TaskKanbanBoardProps) {
5054
const { userId } = useAuth();
55+
const queryClient = useQueryClient();
56+
57+
const handleClearColumn = useCallback(
58+
async (status: TaskStatus) => {
59+
const taskCount =
60+
columns[status]?.filter((item) => item.type === 'task').length ?? 0;
61+
if (taskCount === 0) return;
62+
63+
try {
64+
await BulkDeleteTasksDialog.show({
65+
projectId,
66+
status,
67+
count: taskCount,
68+
});
69+
// Dialog resolved successfully (API already called), invalidate queries
70+
queryClient.invalidateQueries({ queryKey: taskKeys.all });
71+
queryClient.invalidateQueries({ queryKey: taskRelationshipsKeys.all });
72+
} catch {
73+
// User cancelled
74+
}
75+
},
76+
[columns, projectId, queryClient]
77+
);
5178

5279
return (
5380
<KanbanProvider onDragEnd={onDragEnd}>
5481
{Object.entries(columns).map(([status, items]) => {
5582
const statusKey = status as TaskStatus;
83+
const hasOwnTasks = items.some((item) => item.type === 'task');
5684
return (
5785
<KanbanBoard key={status} id={statusKey}>
5886
<KanbanHeader
5987
name={statusLabels[statusKey]}
6088
color={statusBoardColors[statusKey]}
6189
onAddTask={onCreateTask}
90+
onClearColumn={
91+
hasOwnTasks ? () => handleClearColumn(statusKey) : undefined
92+
}
6293
/>
6394
<KanbanCards>
6495
{items.map((item, index) => {

0 commit comments

Comments
 (0)