Skip to content

Commit 0ad188f

Browse files
feat(tasks): implement bulk task actions (complete/delete) (#330)
- Added checkboxes to task rows and "Select All" in header - Implemented floating action panel for selected tasks - Added backend endpoints for bulk complete and delete - Updated frontend state to track selected UUIDs - Added confirmation dialogs and loading states - Added comprehensive tests for bulk selection and actions - Fixes: #178
1 parent 9f6f260 commit 0ad188f

13 files changed

Lines changed: 1265 additions & 129 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package controllers
2+
3+
import (
4+
"ccsync_backend/models"
5+
"ccsync_backend/utils/tw"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
)
11+
12+
// BulkCompleteTaskHandler godoc
13+
// @Summary Bulk complete tasks
14+
// @Description Mark multiple tasks as completed in Taskwarrior
15+
// @Tags Tasks
16+
// @Accept json
17+
// @Produce json
18+
// @Param task body models.BulkCompleteTaskRequestBody true "Bulk task completion details"
19+
// @Success 202 {string} string "Bulk task completion accepted for processing"
20+
// @Failure 400 {string} string "Invalid request - missing or empty taskuuids"
21+
// @Failure 405 {string} string "Method not allowed"
22+
// @Router /complete-tasks [post]
23+
func BulkCompleteTaskHandler(w http.ResponseWriter, r *http.Request) {
24+
if r.Method != http.MethodPost {
25+
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
26+
return
27+
}
28+
29+
body, err := io.ReadAll(r.Body)
30+
if err != nil {
31+
http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
32+
return
33+
}
34+
defer r.Body.Close()
35+
36+
var requestBody models.BulkCompleteTaskRequestBody
37+
38+
if err := json.Unmarshal(body, &requestBody); err != nil {
39+
http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
40+
return
41+
}
42+
43+
email := requestBody.Email
44+
encryptionSecret := requestBody.EncryptionSecret
45+
uuid := requestBody.UUID
46+
taskUUIDs := requestBody.TaskUUIDs
47+
48+
if len(taskUUIDs) == 0 {
49+
http.Error(w, "taskuuids is required and cannot be empty", http.StatusBadRequest)
50+
return
51+
}
52+
53+
logStore := models.GetLogStore()
54+
55+
// Create a *single* job for all UUIDs
56+
job := Job{
57+
Name: "Bulk Complete Tasks",
58+
Execute: func() error {
59+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Starting %d tasks", len(taskUUIDs)), uuid, "Bulk Complete Task")
60+
61+
failedTasks, err := tw.CompleteTasksInTaskwarrior(email, encryptionSecret, uuid, taskUUIDs)
62+
63+
for taskUUID, errMsg := range failedTasks {
64+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Complete] Failed: %s (%s)", taskUUID, errMsg), uuid, "Bulk Complete Task")
65+
}
66+
67+
if err != nil {
68+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Complete] Sync error: %v", err), uuid, "Bulk Complete Task")
69+
return err
70+
}
71+
72+
successCount := len(taskUUIDs) - len(failedTasks)
73+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Finished: %d succeeded, %d failed", successCount, len(failedTasks)), uuid, "Bulk Complete Task")
74+
75+
return nil
76+
},
77+
}
78+
79+
GlobalJobQueue.AddJob(job)
80+
w.WriteHeader(http.StatusAccepted)
81+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package controllers
2+
3+
import (
4+
"ccsync_backend/models"
5+
"ccsync_backend/utils/tw"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
)
11+
12+
// BulkDeleteTaskHandler godoc
13+
// @Summary Bulk delete tasks
14+
// @Description Delete multiple tasks in Taskwarrior
15+
// @Tags Tasks
16+
// @Accept json
17+
// @Produce json
18+
// @Param task body models.BulkDeleteTaskRequestBody true "Bulk task deletion details"
19+
// @Success 202 {string} string "Bulk task deletion accepted for processing"
20+
// @Failure 400 {string} string "Invalid request - missing or empty taskuuids"
21+
// @Failure 405 {string} string "Method not allowed"
22+
// @Router /delete-tasks [post]
23+
func BulkDeleteTaskHandler(w http.ResponseWriter, r *http.Request) {
24+
if r.Method != http.MethodPost {
25+
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
26+
return
27+
}
28+
29+
body, err := io.ReadAll(r.Body)
30+
if err != nil {
31+
http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
32+
return
33+
}
34+
defer r.Body.Close()
35+
36+
var requestBody models.BulkDeleteTaskRequestBody
37+
38+
if err := json.Unmarshal(body, &requestBody); err != nil {
39+
http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
40+
return
41+
}
42+
43+
email := requestBody.Email
44+
encryptionSecret := requestBody.EncryptionSecret
45+
uuid := requestBody.UUID
46+
taskUUIDs := requestBody.TaskUUIDs
47+
48+
if len(taskUUIDs) == 0 {
49+
http.Error(w, "taskuuids is required and cannot be empty", http.StatusBadRequest)
50+
return
51+
}
52+
53+
logStore := models.GetLogStore()
54+
55+
job := Job{
56+
Name: "Bulk Delete Tasks",
57+
Execute: func() error {
58+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Starting %d tasks", len(taskUUIDs)), uuid, "Bulk Delete Task")
59+
60+
failedTasks, err := tw.DeleteTasksInTaskwarrior(email, encryptionSecret, uuid, taskUUIDs)
61+
62+
for taskUUID, errMsg := range failedTasks {
63+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Delete] Failed: %s (%s)", taskUUID, errMsg), uuid, "Bulk Delete Task")
64+
}
65+
66+
if err != nil {
67+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Delete] Sync error: %v", err), uuid, "Bulk Delete Task")
68+
return err
69+
}
70+
71+
successCount := len(taskUUIDs) - len(failedTasks)
72+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Finished: %d succeeded, %d failed", successCount, len(failedTasks)), uuid, "Bulk Delete Task")
73+
74+
return nil
75+
},
76+
}
77+
78+
GlobalJobQueue.AddJob(job)
79+
w.WriteHeader(http.StatusAccepted)
80+
}

backend/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ func main() {
9999
mux.Handle("/complete-task", rateLimitedHandler(http.HandlerFunc(controllers.CompleteTaskHandler)))
100100
mux.Handle("/delete-task", rateLimitedHandler(http.HandlerFunc(controllers.DeleteTaskHandler)))
101101
mux.Handle("/sync/logs", rateLimitedHandler(http.HandlerFunc(controllers.SyncLogsHandler)))
102+
mux.Handle("/complete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkCompleteTaskHandler)))
103+
mux.Handle("/delete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkDeleteTaskHandler)))
102104

103105
mux.HandleFunc("/health", controllers.HealthCheckHandler)
104106

backend/models/request_body.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,15 @@ type DeleteTaskRequestBody struct {
6060
UUID string `json:"UUID"`
6161
TaskUUID string `json:"taskuuid"`
6262
}
63+
type BulkCompleteTaskRequestBody struct {
64+
Email string `json:"email"`
65+
EncryptionSecret string `json:"encryptionSecret"`
66+
UUID string `json:"UUID"`
67+
TaskUUIDs []string `json:"taskuuids"`
68+
}
69+
type BulkDeleteTaskRequestBody struct {
70+
Email string `json:"email"`
71+
EncryptionSecret string `json:"encryptionSecret"`
72+
UUID string `json:"UUID"`
73+
TaskUUIDs []string `json:"taskuuids"`
74+
}

backend/utils/tw/complete_tasks.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package tw
2+
3+
import (
4+
"ccsync_backend/utils"
5+
"fmt"
6+
"os"
7+
)
8+
9+
func CompleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs []string) (map[string]string, error) {
10+
failedTasks := make(map[string]string)
11+
12+
if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
13+
return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err)
14+
}
15+
16+
tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
17+
18+
if err != nil {
19+
return nil, fmt.Errorf("failed to create temporary directory: %v", err)
20+
}
21+
defer os.RemoveAll(tempDir)
22+
23+
origin := os.Getenv("CONTAINER_ORIGIN")
24+
if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
25+
return nil, err
26+
}
27+
28+
if err := SyncTaskwarrior(tempDir); err != nil {
29+
return nil, err
30+
}
31+
32+
for _, taskuuid := range taskUUIDs {
33+
if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "done", "rc.confirmation=off"); err != nil {
34+
failedTasks[taskuuid] = err.Error()
35+
continue
36+
}
37+
}
38+
39+
// Sync Taskwarrior again
40+
if err := SyncTaskwarrior(tempDir); err != nil {
41+
return failedTasks, err
42+
}
43+
44+
return failedTasks, nil
45+
}

backend/utils/tw/delete_tasks.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package tw
2+
3+
import (
4+
"ccsync_backend/utils"
5+
"fmt"
6+
"os"
7+
)
8+
9+
func DeleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs []string) (map[string]string, error) {
10+
failedTasks := make(map[string]string)
11+
12+
if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
13+
return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err)
14+
}
15+
16+
tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
17+
18+
if err != nil {
19+
return nil, fmt.Errorf("failed to create temporary directory: %v", err)
20+
}
21+
defer os.RemoveAll(tempDir)
22+
23+
origin := os.Getenv("CONTAINER_ORIGIN")
24+
if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
25+
return nil, err
26+
}
27+
28+
if err := SyncTaskwarrior(tempDir); err != nil {
29+
return nil, err
30+
}
31+
32+
for _, taskuuid := range taskUUIDs {
33+
if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "delete", "rc.confirmation=off"); err != nil {
34+
failedTasks[taskuuid] = err.Error()
35+
continue
36+
}
37+
}
38+
39+
// Sync Taskwarrior again
40+
if err := SyncTaskwarrior(tempDir); err != nil {
41+
return failedTasks, err
42+
}
43+
44+
return failedTasks, nil
45+
}

frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const TaskDialog = ({
4242
selectedIndex,
4343
onOpenChange,
4444
onSelectTask,
45+
selectedTaskUUIDs,
46+
onCheckboxChange,
4547
editState,
4648
onUpdateState,
4749
allTasks,
@@ -100,6 +102,18 @@ export const TaskDialog = ({
100102
onSelectTask(task, index);
101103
}}
102104
>
105+
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
106+
<input
107+
type="checkbox"
108+
checked={selectedTaskUUIDs.includes(task.uuid)}
109+
disabled={task.status === 'deleted'}
110+
onChange={(e) => {
111+
e.stopPropagation();
112+
onCheckboxChange(task.uuid, e.target.checked);
113+
}}
114+
/>
115+
</TableCell>
116+
103117
{/* Display task details */}
104118
<TableCell className="py-2">
105119
<span

0 commit comments

Comments
 (0)