diff --git a/backend/controllers/add_task.go b/backend/controllers/add_task.go index 62f716b8..e0510162 100644 --- a/backend/controllers/add_task.go +++ b/backend/controllers/add_task.go @@ -2,6 +2,7 @@ package controllers import ( "ccsync_backend/models" + "ccsync_backend/utils" "ccsync_backend/utils/tw" "encoding/json" "fmt" @@ -53,11 +54,18 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) { recur := requestBody.Recur tags := requestBody.Tags annotations := requestBody.Annotations + depends := requestBody.Depends if description == "" { http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest) return } + + // Validate dependencies + if err := utils.ValidateDependencies(depends, ""); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } var dueDateStr string if dueDate != nil && *dueDate != "" { dueDateStr = *dueDate @@ -68,7 +76,7 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) { Name: "Add Task", Execute: func() error { logStore.AddLog("INFO", fmt.Sprintf("Adding task: %s", description), uuid, "Add Task") - err := tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDateStr, start, entryDate, waitDate, end, recur, tags, annotations) + err := tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDateStr, start, entryDate, waitDate, end, recur, tags, annotations, depends) if err != nil { logStore.AddLog("ERROR", fmt.Sprintf("Failed to add task: %v", err), uuid, "Add Task") return err diff --git a/backend/controllers/controllers_test.go b/backend/controllers/controllers_test.go index 88368f05..16127919 100644 --- a/backend/controllers/controllers_test.go +++ b/backend/controllers/controllers_test.go @@ -196,3 +196,79 @@ func Test_AddTaskHandler_MissingDescription(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Contains(t, rr.Body.String(), "Description is required") } + +// Task Dependencies Tests +func Test_AddTaskHandler_WithDependencies(t *testing.T) { + GlobalJobQueue = NewJobQueue() + + requestBody := map[string]interface{}{ + "email": "test@example.com", + "encryptionSecret": "secret", + "UUID": "test-uuid", + "description": "Task with dependencies", + "project": "TestProject", + "priority": "H", + "depends": []string{"task-uuid-1", "task-uuid-2"}, + "tags": []string{"dependent"}, + } + + body, _ := json.Marshal(requestBody) + req, err := http.NewRequest("POST", "/add-task", bytes.NewBuffer(body)) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + AddTaskHandler(rr, req) + + assert.Equal(t, http.StatusAccepted, rr.Code) +} + +func Test_AddTaskHandler_WithEmptyDependencies(t *testing.T) { + GlobalJobQueue = NewJobQueue() + + requestBody := map[string]interface{}{ + "email": "test@example.com", + "encryptionSecret": "secret", + "UUID": "test-uuid", + "description": "Task with empty dependencies", + "project": "TestProject", + "priority": "M", + "depends": []string{}, + "tags": []string{"test"}, + } + + body, _ := json.Marshal(requestBody) + req, err := http.NewRequest("POST", "/add-task", bytes.NewBuffer(body)) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + AddTaskHandler(rr, req) + + assert.Equal(t, http.StatusAccepted, rr.Code) +} + +func Test_EditTaskHandler_WithDependencies(t *testing.T) { + GlobalJobQueue = NewJobQueue() + + requestBody := map[string]interface{}{ + "email": "test@example.com", + "encryptionSecret": "secret", + "UUID": "test-uuid", + "taskID": "1", + "description": "Edited task with dependencies", + "project": "EditedProject", + "depends": []string{"task-uuid-3"}, + "tags": []string{"edited", "dependent"}, + } + + body, _ := json.Marshal(requestBody) + req, err := http.NewRequest("POST", "/edit-task", bytes.NewBuffer(body)) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + EditTaskHandler(rr, req) + + assert.Equal(t, http.StatusAccepted, rr.Code) +} diff --git a/backend/controllers/edit_task.go b/backend/controllers/edit_task.go index 49d0a843..40a6fa59 100644 --- a/backend/controllers/edit_task.go +++ b/backend/controllers/edit_task.go @@ -2,6 +2,7 @@ package controllers import ( "ccsync_backend/models" + "ccsync_backend/utils" "ccsync_backend/utils/tw" "encoding/json" "fmt" @@ -60,6 +61,12 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Validate dependencies + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } + logStore := models.GetLogStore() job := Job{ Name: "Edit Task", diff --git a/backend/controllers/modify_task.go b/backend/controllers/modify_task.go index 573d97a4..e4eb2acd 100644 --- a/backend/controllers/modify_task.go +++ b/backend/controllers/modify_task.go @@ -2,6 +2,7 @@ package controllers import ( "ccsync_backend/models" + "ccsync_backend/utils" "ccsync_backend/utils/tw" "encoding/json" "fmt" @@ -48,6 +49,7 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { status := requestBody.Status due := requestBody.Due tags := requestBody.Tags + depends := requestBody.Depends if description == "" { http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest) @@ -58,6 +60,12 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Validate dependencies + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } + // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil { // http.Error(w, err.Error(), http.StatusInternalServerError) // return @@ -68,7 +76,7 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { Name: "Modify Task", Execute: func() error { logStore.AddLog("INFO", fmt.Sprintf("Modifying task ID: %s", taskID), uuid, "Modify Task") - err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID, tags) + err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID, tags, depends) if err != nil { logStore.AddLog("ERROR", fmt.Sprintf("Failed to modify task ID %s: %v", taskID, err), uuid, "Modify Task") return err diff --git a/backend/models/request_body.go b/backend/models/request_body.go index 91553f12..d4cc4d55 100644 --- a/backend/models/request_body.go +++ b/backend/models/request_body.go @@ -16,6 +16,7 @@ type AddTaskRequestBody struct { Recur string `json:"recur"` Tags []string `json:"tags"` Annotations []Annotation `json:"annotations"` + Depends []string `json:"depends"` } type ModifyTaskRequestBody struct { Email string `json:"email"` @@ -28,6 +29,7 @@ type ModifyTaskRequestBody struct { Status string `json:"status"` Due string `json:"due"` Tags []string `json:"tags"` + Depends []string `json:"depends"` } type EditTaskRequestBody struct { Email string `json:"email"` diff --git a/backend/utils/tw/add_task.go b/backend/utils/tw/add_task.go index 7de36a97..fc3c2df4 100644 --- a/backend/utils/tw/add_task.go +++ b/backend/utils/tw/add_task.go @@ -10,7 +10,7 @@ import ( ) // add task to the user's tw client -func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, start, entryDate string, waitDate string, end, recur string, tags []string, annotations []models.Annotation) error { +func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, start, entryDate string, waitDate string, end, recur string, tags []string, annotations []models.Annotation, depends []string) error { if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { return fmt.Errorf("error deleting Taskwarrior data: %v", err) } @@ -43,6 +43,11 @@ func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, p if start != "" { cmdArgs = append(cmdArgs, "start:"+start) } + // Add dependencies to the task + if len(depends) > 0 { + dependsStr := strings.Join(depends, ",") + cmdArgs = append(cmdArgs, "depends:"+dependsStr) + } if entryDate != "" { cmdArgs = append(cmdArgs, "entry:"+entryDate) } diff --git a/backend/utils/tw/edit_task.go b/backend/utils/tw/edit_task.go index 5849eabe..c276111b 100644 --- a/backend/utils/tw/edit_task.go +++ b/backend/utils/tw/edit_task.go @@ -96,12 +96,10 @@ func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID st } } - // Handle depends - if len(depends) > 0 { - dependsStr := strings.Join(depends, ",") - if err := utils.ExecCommand("task", taskID, "modify", "depends:"+dependsStr); err != nil { - return fmt.Errorf("failed to set depends %s: %v", dependsStr, err) - } + // Handle depends - always set to ensure clearing works + dependsStr := strings.Join(depends, ",") + if err := utils.ExecCommand("task", taskID, "modify", "depends:"+dependsStr); err != nil { + return fmt.Errorf("failed to set depends %s: %v", dependsStr, err) } // Handle due date diff --git a/backend/utils/tw/modify_task.go b/backend/utils/tw/modify_task.go index 286a435e..fa2c3774 100644 --- a/backend/utils/tw/modify_task.go +++ b/backend/utils/tw/modify_task.go @@ -7,7 +7,7 @@ import ( "strings" ) -func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string) error { +func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string, depends []string) error { if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { fmt.Println("1") return fmt.Errorf("error deleting Taskwarrior data: %v", err) @@ -55,6 +55,12 @@ func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, return fmt.Errorf("failed to edit task due: %v", err) } + // Handle dependencies - always set to ensure clearing works + dependsStr := strings.Join(depends, ",") + if err := utils.ExecCommand("task", taskID, "modify", "depends:"+dependsStr); err != nil { + return fmt.Errorf("failed to set dependencies %s: %v", dependsStr, err) + } + // escapedStatus := fmt.Sprintf(`status:%s`, strings.ReplaceAll(status, `"`, `\"`)) if status == "completed" { utils.ExecCommand("task", taskID, "done", "rc.confirmation=off") diff --git a/backend/utils/tw/taskwarrior_test.go b/backend/utils/tw/taskwarrior_test.go index 32ca3f30..6c69d581 100644 --- a/backend/utils/tw/taskwarrior_test.go +++ b/backend/utils/tw/taskwarrior_test.go @@ -43,7 +43,7 @@ func TestExportTasks(t *testing.T) { } func TestAddTaskToTaskwarrior(t *testing.T) { - err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", nil, []models.Annotation{{Description: "note"}}) + err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", nil, []models.Annotation{{Description: "note"}}, []string{}) if err != nil { t.Errorf("AddTaskToTaskwarrior failed: %v", err) } else { @@ -79,7 +79,7 @@ func TestCompleteTaskInTaskwarrior(t *testing.T) { } func TestAddTaskWithTags(t *testing.T) { - err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", []string{"work", "important"}, []models.Annotation{{Description: "note"}}) + err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", []string{"work", "important"}, []models.Annotation{{Description: "note"}}, []string{}) if err != nil { t.Errorf("AddTaskToTaskwarrior with tags failed: %v", err) } else { @@ -133,7 +133,7 @@ func TestEditTaskWithMixedTagOperations(t *testing.T) { } func TestModifyTaskWithTags(t *testing.T) { - err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}) + err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}, []string{}) if err != nil { t.Errorf("ModifyTaskInTaskwarrior with tags failed: %v", err) } else { diff --git a/backend/utils/utils_test.go b/backend/utils/utils_test.go index ab2c2e56..b8fe26f3 100644 --- a/backend/utils/utils_test.go +++ b/backend/utils/utils_test.go @@ -84,3 +84,17 @@ func Test_ExecCommandForOutputInDir(t *testing.T) { t.Errorf("Expected output but got empty result") } } + +func Test_ValidateDependencies_ValidDependencies(t *testing.T) { + depends := []string{"task-uuid-1", "task-uuid-2"} + currentTaskUUID := "current-task-uuid" + err := ValidateDependencies(depends, currentTaskUUID) + assert.NoError(t, err) +} + +func Test_ValidateDependencies_EmptyList(t *testing.T) { + depends := []string{} + currentTaskUUID := "current-task-uuid" + err := ValidateDependencies(depends, currentTaskUUID) + assert.NoError(t, err) +} diff --git a/backend/utils/validation.go b/backend/utils/validation.go new file mode 100644 index 00000000..a439e055 --- /dev/null +++ b/backend/utils/validation.go @@ -0,0 +1,21 @@ +package utils + +import ( + "fmt" +) + +// ValidateDependencies validates dependencies +func ValidateDependencies(depends []string, currentTaskUUID string) error { + if len(depends) == 0 { + return nil + } + + // check for self-dependency + for _, dep := range depends { + if dep == currentTaskUUID { + return fmt.Errorf("task cannot depend on itself: %s", dep) + } + } + + return nil +} diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index d9b085c7..a4c93a83 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -35,8 +35,39 @@ export const AddTaskdialog = ({ isCreatingNewProject, setIsCreatingNewProject, uniqueProjects = [], + allTasks = [], // Add this prop }: AddTaskDialogProps) => { const [annotationInput, setAnnotationInput] = useState(''); + const [dependencySearch, setDependencySearch] = useState(''); + const [showDependencyResults, setShowDependencyResults] = useState(false); + + const getFilteredTasks = () => { + const availableTasks = allTasks.filter( + (task) => + task.status === 'pending' && !newTask.depends.includes(task.uuid) + ); + + if (dependencySearch.trim() === '') { + return []; + } + + return availableTasks + .filter( + (task) => + task.description + .toLowerCase() + .includes(dependencySearch.toLowerCase()) || + (task.project && + task.project + .toLowerCase() + .includes(dependencySearch.toLowerCase())) || + (task.tags && + task.tags.some((tag) => + tag.toLowerCase().includes(dependencySearch.toLowerCase()) + )) + ) + .slice(0, 5); + }; useEffect(() => { if (!isOpen) { @@ -406,6 +437,118 @@ export const AddTaskdialog = ({ )} +