Skip to content

Commit 6014972

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 543a4a8 commit 6014972

File tree

8 files changed

+970
-126
lines changed

8 files changed

+970
-126
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
@@ -51,7 +51,9 @@ import {
5151
handleCopy,
5252
handleDate,
5353
markTaskAsCompleted,
54+
bulkMarkTasksAsCompleted,
5455
markTaskAsDeleted,
56+
bulkMarkTasksAsDeleted,
5557
Props,
5658
sortTasks,
5759
sortTasksById,
@@ -137,6 +139,7 @@ export const Tasks = (
137139
const [searchTerm, setSearchTerm] = useState('');
138140
const [debouncedTerm, setDebouncedTerm] = useState('');
139141
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
142+
const [selectedTaskUUIDs, setSelectedTaskUUIDs] = useState<string[]>([]);
140143
const tableRef = useRef<HTMLDivElement>(null);
141144
const [hotkeysEnabled, setHotkeysEnabled] = useState(false);
142145
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -402,6 +405,36 @@ export const Tasks = (
402405
}
403406
}
404407

408+
const handleBulkComplete = async () => {
409+
if (selectedTaskUUIDs.length === 0) return;
410+
411+
const success = await bulkMarkTasksAsCompleted(
412+
props.email,
413+
props.encryptionSecret,
414+
props.UUID,
415+
selectedTaskUUIDs
416+
);
417+
418+
if (success) {
419+
setSelectedTaskUUIDs([]);
420+
}
421+
};
422+
423+
const handleBulkDelete = async () => {
424+
if (selectedTaskUUIDs.length === 0) return;
425+
426+
const success = await bulkMarkTasksAsDeleted(
427+
props.email,
428+
props.encryptionSecret,
429+
props.UUID,
430+
selectedTaskUUIDs
431+
);
432+
433+
if (success) {
434+
setSelectedTaskUUIDs([]);
435+
}
436+
};
437+
405438
const handleIdSort = () => {
406439
const newOrder = idSortOrder === 'asc' ? 'desc' : 'asc';
407440
setIdSortOrder(newOrder);
@@ -894,7 +927,7 @@ export const Tasks = (
894927
>
895928
{tasks.length != 0 ? (
896929
<>
897-
<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">
930+
<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">
898931
{/* Table for displaying tasks */}
899932
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
900933
<h3 className="ml-4 mb-4 mr-4 text-2xl mt-0 md:text-2xl font-bold">
@@ -1149,6 +1182,24 @@ export const Tasks = (
11491182
<Table className="w-full text-white">
11501183
<TableHeader>
11511184
<TableRow>
1185+
<TableHead>
1186+
<input
1187+
type="checkbox"
1188+
checked={
1189+
currentTasks.length > 0 &&
1190+
selectedTaskUUIDs.length === currentTasks.length
1191+
}
1192+
onChange={(e) => {
1193+
if (e.target.checked) {
1194+
setSelectedTaskUUIDs(
1195+
currentTasks.map((task) => task.uuid)
1196+
);
1197+
} else {
1198+
setSelectedTaskUUIDs([]);
1199+
}
1200+
}}
1201+
/>
1202+
</TableHead>
11521203
<TableHead
11531204
className="py-2 w-0.20/6"
11541205
onClick={handleIdSort}
@@ -1202,6 +1253,27 @@ export const Tasks = (
12021253
key={index}
12031254
className={`border-b cursor-pointer ${selectedIndex === index ? 'bg-muted/50' : ''}`}
12041255
>
1256+
<TableCell className="w-[40px]">
1257+
<input
1258+
type="checkbox"
1259+
checked={selectedTaskUUIDs.includes(
1260+
task.uuid
1261+
)}
1262+
onClick={(e) => e.stopPropagation()}
1263+
onChange={(e) => {
1264+
if (e.target.checked) {
1265+
setSelectedTaskUUIDs((prev) => [
1266+
...prev,
1267+
task.uuid,
1268+
]);
1269+
} else {
1270+
setSelectedTaskUUIDs((prev) =>
1271+
prev.filter((id) => id !== task.uuid)
1272+
);
1273+
}
1274+
}}
1275+
/>
1276+
</TableCell>
12051277
{/* Display task details */}
12061278
<TableCell className="py-2">
12071279
<span
@@ -2302,6 +2374,91 @@ export const Tasks = (
23022374
{/* Intentionally empty for spacing */}
23032375
</div>
23042376
</div>
2377+
{selectedTaskUUIDs.length > 0 && (
2378+
<div
2379+
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"
2380+
data-testid="bulk-action-bar"
2381+
>
2382+
{/* Bulk Complete Dialog */}
2383+
<Dialog>
2384+
<DialogTrigger asChild>
2385+
<Button
2386+
variant="default"
2387+
data-testid="bulk-complete-btn"
2388+
>
2389+
Mark {selectedTaskUUIDs.length}{' '}
2390+
{selectedTaskUUIDs.length === 1 ? 'Task' : 'Tasks'}{' '}
2391+
Completed
2392+
</Button>
2393+
</DialogTrigger>
2394+
2395+
<DialogContent>
2396+
<DialogTitle className="text-2xl font-bold">
2397+
<span className="bg-gradient-to-r from-[#F596D3] to-[#D247BF] text-transparent bg-clip-text">
2398+
Are you
2399+
</span>{' '}
2400+
sure?
2401+
</DialogTitle>
2402+
2403+
<DialogFooter className="flex flex-row justify-center">
2404+
<DialogClose asChild>
2405+
<Button
2406+
className="mr-5"
2407+
onClick={async () => {
2408+
await handleBulkComplete();
2409+
}}
2410+
>
2411+
Yes
2412+
</Button>
2413+
</DialogClose>
2414+
2415+
<DialogClose asChild>
2416+
<Button variant="destructive">No</Button>
2417+
</DialogClose>
2418+
</DialogFooter>
2419+
</DialogContent>
2420+
</Dialog>
2421+
2422+
{/* Bulk Delete Dialog */}
2423+
<Dialog>
2424+
<DialogTrigger asChild>
2425+
<Button
2426+
variant="destructive"
2427+
data-testid="bulk-delete-btn"
2428+
>
2429+
Delete {selectedTaskUUIDs.length}{' '}
2430+
{selectedTaskUUIDs.length === 1 ? 'Task' : 'Tasks'}
2431+
</Button>
2432+
</DialogTrigger>
2433+
2434+
<DialogContent>
2435+
<DialogTitle className="text-2xl font-bold">
2436+
<span className="bg-gradient-to-r from-[#F596D3] to-[#D247BF] text-transparent bg-clip-text">
2437+
Are you
2438+
</span>{' '}
2439+
sure?
2440+
</DialogTitle>
2441+
2442+
<DialogFooter className="flex flex-row justify-center">
2443+
<DialogClose asChild>
2444+
<Button
2445+
className="mr-5"
2446+
onClick={async () => {
2447+
await handleBulkDelete();
2448+
}}
2449+
>
2450+
Yes
2451+
</Button>
2452+
</DialogClose>
2453+
2454+
<DialogClose asChild>
2455+
<Button variant="destructive">No</Button>
2456+
</DialogClose>
2457+
</DialogFooter>
2458+
</DialogContent>
2459+
</Dialog>
2460+
</div>
2461+
)}
23052462
</div>
23062463
</>
23072464
) : (

0 commit comments

Comments
 (0)