Skip to content

Commit f23ebbc

Browse files
committed
feat(tasks): implement bulk task actions (complete/delete)
- 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 7a9a984 commit f23ebbc

File tree

8 files changed

+972
-125
lines changed

8 files changed

+972
-125
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
for _, tu := range taskUUIDs {
60+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Starting: %s", tu), uuid, "Bulk Complete Task")
61+
62+
err := tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, tu)
63+
if err != nil {
64+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Complete] Failed: %s (%v)", tu, err), uuid, "Bulk Complete Task")
65+
continue
66+
}
67+
68+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Complete] Completed: %s", tu), uuid, "Bulk Complete Task")
69+
}
70+
return nil
71+
},
72+
}
73+
74+
GlobalJobQueue.AddJob(job)
75+
w.WriteHeader(http.StatusAccepted)
76+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
for _, tu := range taskUUIDs {
59+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Starting: %s", tu), uuid, "Bulk Delete Task")
60+
61+
err := tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, tu)
62+
if err != nil {
63+
logStore.AddLog("ERROR", fmt.Sprintf("[Bulk Delete] Failed: %s (%v)", tu, err), uuid, "Bulk Delete Task")
64+
continue
65+
}
66+
67+
logStore.AddLog("INFO", fmt.Sprintf("[Bulk Delete] Deleted: %s", tu), uuid, "Bulk Delete Task")
68+
}
69+
return nil
70+
},
71+
}
72+
73+
GlobalJobQueue.AddJob(job)
74+
w.WriteHeader(http.StatusAccepted)
75+
}

backend/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ func main() {
9292
mux.Handle("/complete-task", rateLimitedHandler(http.HandlerFunc(controllers.CompleteTaskHandler)))
9393
mux.Handle("/delete-task", rateLimitedHandler(http.HandlerFunc(controllers.DeleteTaskHandler)))
9494
mux.Handle("/sync/logs", rateLimitedHandler(http.HandlerFunc(controllers.SyncLogsHandler)))
95+
mux.Handle("/complete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkCompleteTaskHandler)))
96+
mux.Handle("/delete-tasks", rateLimitedHandler(http.HandlerFunc(controllers.BulkDeleteTaskHandler)))
9597

9698
mux.HandleFunc("/ws", controllers.WebSocketHandler)
9799

backend/models/request_body.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,15 @@ type DeleteTaskRequestBody struct {
4949
UUID string `json:"UUID"`
5050
TaskUUID string `json:"taskuuid"`
5151
}
52+
type BulkCompleteTaskRequestBody struct {
53+
Email string `json:"email"`
54+
EncryptionSecret string `json:"encryptionSecret"`
55+
UUID string `json:"UUID"`
56+
TaskUUIDs []string `json:"taskuuids"`
57+
}
58+
type BulkDeleteTaskRequestBody struct {
59+
Email string `json:"email"`
60+
EncryptionSecret string `json:"encryptionSecret"`
61+
UUID string `json:"UUID"`
62+
TaskUUIDs []string `json:"taskuuids"`
63+
}

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

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ import {
5050
handleCopy,
5151
handleDate,
5252
markTaskAsCompleted,
53+
bulkMarkTasksAsCompleted,
5354
markTaskAsDeleted,
55+
bulkMarkTasksAsDeleted,
5456
Props,
5557
sortTasks,
5658
sortTasksById,
@@ -135,6 +137,7 @@ export const Tasks = (
135137
const [searchTerm, setSearchTerm] = useState('');
136138
const [debouncedTerm, setDebouncedTerm] = useState('');
137139
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
140+
const [selectedTaskUUIDs, setSelectedTaskUUIDs] = useState<string[]>([]);
138141

139142
const isOverdue = (due?: string) => {
140143
if (!due) return false;
@@ -361,6 +364,36 @@ export const Tasks = (
361364
}
362365
}
363366

367+
const handleBulkComplete = async () => {
368+
if (selectedTaskUUIDs.length === 0) return;
369+
370+
const success = await bulkMarkTasksAsCompleted(
371+
props.email,
372+
props.encryptionSecret,
373+
props.UUID,
374+
selectedTaskUUIDs
375+
);
376+
377+
if (success) {
378+
setSelectedTaskUUIDs([]);
379+
}
380+
};
381+
382+
const handleBulkDelete = async () => {
383+
if (selectedTaskUUIDs.length === 0) return;
384+
385+
const success = await bulkMarkTasksAsDeleted(
386+
props.email,
387+
props.encryptionSecret,
388+
props.UUID,
389+
selectedTaskUUIDs
390+
);
391+
392+
if (success) {
393+
setSelectedTaskUUIDs([]);
394+
}
395+
};
396+
364397
const handleIdSort = () => {
365398
const newOrder = idSortOrder === 'asc' ? 'desc' : 'asc';
366399
setIdSortOrder(newOrder);
@@ -782,7 +815,7 @@ export const Tasks = (
782815
<>
783816
{tasks.length != 0 ? (
784817
<>
785-
<div className="mt-10 pl-1 md:pl-4 pr-1 md:pr-4 bg-muted/50 border shadow-md rounded-lg p-4 h-full pt-12 pb-6">
818+
<div className="mt-10 pl-1 md:pl-4 pr-1 md:pr-4 bg-muted/50 border shadow-md rounded-lg p-4 h-full pt-12 pb-6 relative overflow-y-auto">
786819
{/* Table for displaying tasks */}
787820
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
788821
<h3 className="ml-4 mb-4 mr-4 text-2xl mt-0 md:text-2xl font-bold">
@@ -1031,6 +1064,24 @@ export const Tasks = (
10311064
<Table className="w-full text-white">
10321065
<TableHeader>
10331066
<TableRow>
1067+
<TableHead>
1068+
<input
1069+
type="checkbox"
1070+
checked={
1071+
currentTasks.length > 0 &&
1072+
selectedTaskUUIDs.length === currentTasks.length
1073+
}
1074+
onChange={(e) => {
1075+
if (e.target.checked) {
1076+
setSelectedTaskUUIDs(
1077+
currentTasks.map((task) => task.uuid)
1078+
);
1079+
} else {
1080+
setSelectedTaskUUIDs([]);
1081+
}
1082+
}}
1083+
/>
1084+
</TableHead>
10341085
<TableHead
10351086
className="py-2 w-0.20/6"
10361087
onClick={handleIdSort}
@@ -1080,6 +1131,27 @@ export const Tasks = (
10801131
>
10811132
<DialogTrigger asChild>
10821133
<TableRow key={index} className="border-b">
1134+
<TableCell className="w-[40px]">
1135+
<input
1136+
type="checkbox"
1137+
checked={selectedTaskUUIDs.includes(
1138+
task.uuid
1139+
)}
1140+
onClick={(e) => e.stopPropagation()}
1141+
onChange={(e) => {
1142+
if (e.target.checked) {
1143+
setSelectedTaskUUIDs((prev) => [
1144+
...prev,
1145+
task.uuid,
1146+
]);
1147+
} else {
1148+
setSelectedTaskUUIDs((prev) =>
1149+
prev.filter((id) => id !== task.uuid)
1150+
);
1151+
}
1152+
}}
1153+
/>
1154+
</TableCell>
10831155
{/* Display task details */}
10841156
<TableCell className="py-2">
10851157
<span
@@ -2170,6 +2242,91 @@ export const Tasks = (
21702242
{/* Intentionally empty for spacing */}
21712243
</div>
21722244
</div>
2245+
{selectedTaskUUIDs.length > 0 && (
2246+
<div
2247+
className="sticky bottom-0 left-1/2 -translate-x-1/2 w-fit bg-black border border-white rounded-lg shadow-xl p-1.5 mt-4 flex gap-4 z-50"
2248+
data-testid="bulk-action-bar"
2249+
>
2250+
{/* Bulk Complete Dialog */}
2251+
<Dialog>
2252+
<DialogTrigger asChild>
2253+
<Button
2254+
variant="default"
2255+
data-testid="bulk-complete-btn"
2256+
>
2257+
Mark {selectedTaskUUIDs.length}{' '}
2258+
{selectedTaskUUIDs.length === 1 ? 'Task' : 'Tasks'}{' '}
2259+
Completed
2260+
</Button>
2261+
</DialogTrigger>
2262+
2263+
<DialogContent>
2264+
<DialogTitle className="text-2xl font-bold">
2265+
<span className="bg-gradient-to-r from-[#F596D3] to-[#D247BF] text-transparent bg-clip-text">
2266+
Are you
2267+
</span>{' '}
2268+
sure?
2269+
</DialogTitle>
2270+
2271+
<DialogFooter className="flex flex-row justify-center">
2272+
<DialogClose asChild>
2273+
<Button
2274+
className="mr-5"
2275+
onClick={async () => {
2276+
await handleBulkComplete();
2277+
}}
2278+
>
2279+
Yes
2280+
</Button>
2281+
</DialogClose>
2282+
2283+
<DialogClose asChild>
2284+
<Button variant="destructive">No</Button>
2285+
</DialogClose>
2286+
</DialogFooter>
2287+
</DialogContent>
2288+
</Dialog>
2289+
2290+
{/* Bulk Delete Dialog */}
2291+
<Dialog>
2292+
<DialogTrigger asChild>
2293+
<Button
2294+
variant="destructive"
2295+
data-testid="bulk-delete-btn"
2296+
>
2297+
Delete {selectedTaskUUIDs.length}{' '}
2298+
{selectedTaskUUIDs.length === 1 ? 'Task' : 'Tasks'}
2299+
</Button>
2300+
</DialogTrigger>
2301+
2302+
<DialogContent>
2303+
<DialogTitle className="text-2xl font-bold">
2304+
<span className="bg-gradient-to-r from-[#F596D3] to-[#D247BF] text-transparent bg-clip-text">
2305+
Are you
2306+
</span>{' '}
2307+
sure?
2308+
</DialogTitle>
2309+
2310+
<DialogFooter className="flex flex-row justify-center">
2311+
<DialogClose asChild>
2312+
<Button
2313+
className="mr-5"
2314+
onClick={async () => {
2315+
await handleBulkDelete();
2316+
}}
2317+
>
2318+
Yes
2319+
</Button>
2320+
</DialogClose>
2321+
2322+
<DialogClose asChild>
2323+
<Button variant="destructive">No</Button>
2324+
</DialogClose>
2325+
</DialogFooter>
2326+
</DialogContent>
2327+
</Dialog>
2328+
</div>
2329+
)}
21732330
</div>
21742331
</>
21752332
) : (

0 commit comments

Comments
 (0)