feat: associated task status with task list not space
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m52s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m52s
This commit is contained in:
@@ -288,12 +288,12 @@ func main() {
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
||||
|
||||
// Task status endpoints
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||
// Task status endpoints (scoped to task list)
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||
|
||||
// File explorer endpoints (space-scoped)
|
||||
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
|
||||
|
||||
@@ -530,13 +530,13 @@ type ReorderTaskStatusesRequest struct {
|
||||
|
||||
// TaskStatusDTO represents a task status in API responses.
|
||||
type TaskStatusDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"task_list_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateTaskListRequest represents task list creation input.
|
||||
@@ -620,13 +620,13 @@ func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
|
||||
// NewTaskStatusDTO creates a DTO from a task status entity.
|
||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||
return &TaskStatusDTO{
|
||||
ID: status.ID.Hex(),
|
||||
SpaceID: status.SpaceID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
ID: status.ID.Hex(),
|
||||
TaskListID: status.TaskListID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ func NewTaskService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
|
||||
|
||||
for idx, status := range defaults {
|
||||
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
Name: status.name,
|
||||
Color: status.color,
|
||||
Order: idx,
|
||||
TaskListID: taskListID,
|
||||
Name: status.name,
|
||||
Color: status.color,
|
||||
Order: idx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
|
||||
func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
|
||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||
if err != nil || status.SpaceID != spaceID {
|
||||
if err != nil || status.TaskListID != taskListID {
|
||||
return nil, errors.New("invalid task status")
|
||||
}
|
||||
return status, nil
|
||||
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
|
||||
return depth, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
|
||||
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
|
||||
if currentStatusID == requestedStatusID {
|
||||
return true, nil
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -205,10 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid parent task")
|
||||
@@ -226,6 +222,10 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
noteLinks, err := toObjectIDs(req.NoteLinks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid task status")
|
||||
}
|
||||
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil {
|
||||
if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
|
||||
return nil, validateErr
|
||||
}
|
||||
statusID = parsedStatusID
|
||||
@@ -299,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
status, err := s.validateStatus(ctx, spaceID, task.StatusID)
|
||||
status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -331,9 +331,6 @@ func (s *TaskService) ListTasks(
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filters := map[string]any{}
|
||||
if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
|
||||
@@ -403,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
|
||||
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
|
||||
for _, status := range statuses {
|
||||
statusByID[status.ID] = status
|
||||
}
|
||||
|
||||
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect statuses per task list
|
||||
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
|
||||
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
|
||||
byID, ok := statusCache[taskListID]
|
||||
if !ok {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
|
||||
for _, st := range statuses {
|
||||
byID[st.ID] = st
|
||||
}
|
||||
statusCache[taskListID] = byID
|
||||
}
|
||||
return byID[statusID]
|
||||
}
|
||||
|
||||
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
status := statusByID[task.StatusID]
|
||||
status := getStatus(task.TaskListID, task.StatusID)
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
@@ -509,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid status")
|
||||
}
|
||||
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil {
|
||||
if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
|
||||
adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -582,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -816,17 +823,24 @@ func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, u
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
|
||||
}
|
||||
|
||||
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
|
||||
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -837,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -848,16 +862,19 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
Order: len(statuses),
|
||||
TaskListID: taskListID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
Order: len(statuses),
|
||||
}
|
||||
if status.Name == "" {
|
||||
return nil, errors.New("status name is required")
|
||||
@@ -868,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
||||
return dto.NewTaskStatusDTO(status), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -881,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
||||
}
|
||||
|
||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||
if err != nil || status.SpaceID != spaceID {
|
||||
if err != nil || status.TaskListID != taskListID {
|
||||
return nil, errors.New("task status not found")
|
||||
}
|
||||
|
||||
@@ -896,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
||||
return dto.NewTaskStatusDTO(status), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error {
|
||||
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -908,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -928,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
||||
return err
|
||||
}
|
||||
|
||||
return s.normalizeStatusOrder(ctx, spaceID)
|
||||
return s.normalizeStatusOrder(ctx, taskListID)
|
||||
}
|
||||
|
||||
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
|
||||
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -942,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -965,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
}
|
||||
status := statusByID[statusID]
|
||||
if status == nil {
|
||||
return nil, errors.New("status id does not belong to this space")
|
||||
return nil, errors.New("status id does not belong to this task list")
|
||||
}
|
||||
if _, exists := seen[statusID]; exists {
|
||||
return nil, errors.New("duplicate status id in ordered_status_ids")
|
||||
@@ -996,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
}
|
||||
}
|
||||
|
||||
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1008,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ type Task struct {
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskStatus defines the ordered linear status progression for a space.
|
||||
// TaskStatus defines the ordered linear status progression for a task list.
|
||||
type TaskStatus struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
Name string `bson:"name"`
|
||||
Color string `bson:"color,omitempty"`
|
||||
Order int `bson:"order"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||
Name string `bson:"name"`
|
||||
Color string `bson:"color,omitempty"`
|
||||
Order int `bson:"order"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskList groups tasks under a named list that can be attached to a category.
|
||||
|
||||
@@ -245,7 +245,8 @@ type TaskListRepository interface {
|
||||
type TaskStatusRepository interface {
|
||||
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
|
||||
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error)
|
||||
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
|
||||
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
||||
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||
}
|
||||
|
||||
@@ -232,8 +232,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) {
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
|
||||
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,14 +257,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}},
|
||||
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}},
|
||||
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -380,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID)
|
||||
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -397,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTaskStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -404,7 +414,7 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req)
|
||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -421,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
@@ -433,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req)
|
||||
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -449,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil {
|
||||
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -468,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.ReorderTaskStatusesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -475,7 +500,7 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs)
|
||||
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
317
frontend/src/components/EditTaskListModal.vue
Normal file
317
frontend/src/components/EditTaskListModal.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Task List</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Task List Details -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="editTaskListName">Name</label>
|
||||
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
|
||||
|
||||
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
|
||||
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
|
||||
<option :value="null">No category</option>
|
||||
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Status Progression -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Status Progression</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="status-item"
|
||||
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
|
||||
draggable="true"
|
||||
@dragstart="onStatusDragStart(status.id)"
|
||||
@dragover.prevent="onStatusDragOver(status.id)"
|
||||
@dragleave="onStatusDragLeave(status.id)"
|
||||
@drop.prevent="onStatusDrop(status.id)"
|
||||
@dragend="onStatusDragEnd"
|
||||
>
|
||||
<span class="drag-handle" aria-hidden="true">
|
||||
<i class="mdi mdi-drag-horizontal-variant"></i>
|
||||
</span>
|
||||
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-name">{{ status.name }}</span>
|
||||
<div class="status-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<DangerZonePanel
|
||||
v-if="canDeleteTaskList"
|
||||
title-id="edit-task-list-danger-zone"
|
||||
title="Danger Zone"
|
||||
description="Delete this task list, all associated tasks, and statuses permanently."
|
||||
>
|
||||
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Task List
|
||||
</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
|
||||
<!-- Status Create/Edit Sub-Modal -->
|
||||
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @click.self="closeStatusModal">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="editStatusName">Status Name</label>
|
||||
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
|
||||
|
||||
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
|
||||
<div class="status-color-row">
|
||||
<input id="editStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
|
||||
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="statusMode === 'edit'"
|
||||
class="mt-4"
|
||||
title-id="edit-status-danger-zone"
|
||||
title="Danger Zone"
|
||||
description="Deleting this status is permanent and cannot be undone."
|
||||
>
|
||||
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @click="submitStatusForm">
|
||||
{{ statusMode === "create" ? "Create" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showStatusModal" class="modal-backdrop fade show" style="z-index: 1055"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
taskList: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canDeleteTaskList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
|
||||
|
||||
const listForm = ref({ name: "", category_id: null });
|
||||
|
||||
watch(
|
||||
() => props.taskList,
|
||||
(tl) => {
|
||||
listForm.value = {
|
||||
name: tl?.name || "",
|
||||
category_id: tl?.category_id || null,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const saveListDetails = () => {
|
||||
const name = listForm.value.name?.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
emit("update-task-list", {
|
||||
name,
|
||||
category_id: listForm.value.category_id || null,
|
||||
});
|
||||
};
|
||||
|
||||
// Status drag-and-drop reorder
|
||||
const draggedStatusId = ref("");
|
||||
const dragOverStatusId = ref("");
|
||||
|
||||
const onStatusDragStart = (id) => {
|
||||
draggedStatusId.value = id;
|
||||
};
|
||||
const onStatusDragOver = (id) => {
|
||||
dragOverStatusId.value = id;
|
||||
};
|
||||
const onStatusDragLeave = (id) => {
|
||||
if (dragOverStatusId.value === id) {
|
||||
dragOverStatusId.value = "";
|
||||
}
|
||||
};
|
||||
const onStatusDrop = (targetId) => {
|
||||
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
const ordered = props.statuses.map((s) => s.id);
|
||||
const fromIndex = ordered.indexOf(draggedStatusId.value);
|
||||
const targetIndex = ordered.indexOf(targetId);
|
||||
if (fromIndex < 0 || targetIndex < 0) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
ordered.splice(fromIndex, 1);
|
||||
const insertIndex = ordered.indexOf(targetId);
|
||||
ordered.splice(insertIndex, 0, draggedStatusId.value);
|
||||
emit("reorder-status", ordered);
|
||||
onStatusDragEnd();
|
||||
};
|
||||
const onStatusDragEnd = () => {
|
||||
draggedStatusId.value = "";
|
||||
dragOverStatusId.value = "";
|
||||
};
|
||||
|
||||
// Status create/edit modal
|
||||
const showStatusModal = ref(false);
|
||||
const statusMode = ref("create");
|
||||
const editingStatusId = ref("");
|
||||
const statusForm = ref({ name: "", color: "#7c8596" });
|
||||
|
||||
const openCreateStatusModal = () => {
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = { name: "", color: "#7c8596" };
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const openEditStatusModal = (status) => {
|
||||
statusMode.value = "edit";
|
||||
editingStatusId.value = status.id;
|
||||
statusForm.value = { name: status.name || "", color: status.color || "#7c8596" };
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const closeStatusModal = () => {
|
||||
showStatusModal.value = false;
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = { name: "", color: "#7c8596" };
|
||||
};
|
||||
|
||||
const submitStatusForm = () => {
|
||||
const name = statusForm.value.name?.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const color = statusForm.value.color?.trim() || "";
|
||||
if (statusMode.value === "create") {
|
||||
emit("create-status", { name, color });
|
||||
} else if (editingStatusId.value) {
|
||||
emit("rename-status", { id: editingStatusId.value, name, color });
|
||||
}
|
||||
closeStatusModal();
|
||||
};
|
||||
|
||||
const deleteStatusFromModal = () => {
|
||||
if (statusMode.value !== "edit" || !editingStatusId.value) {
|
||||
return;
|
||||
}
|
||||
emit("delete-status", {
|
||||
id: editingStatusId.value,
|
||||
name: statusForm.value.name?.trim() || "",
|
||||
color: statusForm.value.color?.trim() || "",
|
||||
});
|
||||
closeStatusModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.status-item.is-drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-color-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-color-row .form-control-color {
|
||||
width: 40px;
|
||||
padding: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
<h4 class="mb-0">Tasks</h4>
|
||||
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||
</div>
|
||||
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
|
||||
</div>
|
||||
|
||||
<div class="task-filters">
|
||||
@@ -23,36 +24,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="status-lane">
|
||||
<div class="lane-header">
|
||||
<strong>Status Progression</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="status-item"
|
||||
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
|
||||
draggable="true"
|
||||
@dragstart="onStatusDragStart(status.id)"
|
||||
@dragover.prevent="onStatusDragOver(status.id)"
|
||||
@dragleave="onStatusDragLeave(status.id)"
|
||||
@drop.prevent="onStatusDrop(status.id)"
|
||||
@dragend="onStatusDragEnd"
|
||||
>
|
||||
<span class="drag-handle" aria-hidden="true">
|
||||
<i class="mdi mdi-drag-horizontal-variant"></i>
|
||||
</span>
|
||||
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-name">{{ status.name }}</span>
|
||||
<div class="status-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-status-groups">
|
||||
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
||||
|
||||
@@ -184,65 +155,11 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="selectedTaskList && canDeleteTaskList"
|
||||
title-id="task-list-danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Delete this task list and all associated tasks permanently."
|
||||
>
|
||||
<button type="button" class="btn btn-danger" @click="emitDeleteTaskList">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Task List
|
||||
</button>
|
||||
</DangerZonePanel>
|
||||
|
||||
<teleport to="body">
|
||||
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="taskStatusName">Status Name</label>
|
||||
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
|
||||
|
||||
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
|
||||
<div class="status-color-row">
|
||||
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
|
||||
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="statusMode === 'edit'"
|
||||
class="mt-4"
|
||||
title-id="status-danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Deleting this status is permanent and cannot be undone."
|
||||
copy-class="mb-2"
|
||||
>
|
||||
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @click="submitStatusForm">
|
||||
{{ statusMode === "create" ? "Create" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
tasks: {
|
||||
@@ -257,27 +174,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
canDeleteTaskList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status", "delete-task-list"]);
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
|
||||
|
||||
const filterStatus = ref("");
|
||||
const filterParent = ref("");
|
||||
const showStatusModal = ref(false);
|
||||
const statusMode = ref("create");
|
||||
const editingStatusId = ref("");
|
||||
const draggedStatusId = ref("");
|
||||
const dragOverStatusId = ref("");
|
||||
const expandedTaskIds = ref({});
|
||||
const openStatusMenuTaskId = ref("");
|
||||
const statusForm = ref({
|
||||
name: "",
|
||||
color: "#7c8596",
|
||||
});
|
||||
|
||||
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
||||
const tasksById = computed(() => {
|
||||
@@ -389,120 +293,6 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocumentClick);
|
||||
});
|
||||
|
||||
const onStatusDragStart = (statusId) => {
|
||||
draggedStatusId.value = statusId;
|
||||
};
|
||||
|
||||
const onStatusDragOver = (statusId) => {
|
||||
dragOverStatusId.value = statusId;
|
||||
};
|
||||
|
||||
const onStatusDragLeave = (statusId) => {
|
||||
if (dragOverStatusId.value === statusId) {
|
||||
dragOverStatusId.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusDrop = (targetStatusId) => {
|
||||
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const ordered = props.statuses.map((item) => item.id);
|
||||
const fromIndex = ordered.indexOf(draggedStatusId.value);
|
||||
const targetIndex = ordered.indexOf(targetStatusId);
|
||||
if (fromIndex < 0 || targetIndex < 0) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
ordered.splice(fromIndex, 1);
|
||||
const insertIndex = ordered.indexOf(targetStatusId);
|
||||
ordered.splice(insertIndex, 0, draggedStatusId.value);
|
||||
|
||||
emit("reorder-status", ordered);
|
||||
onStatusDragEnd();
|
||||
};
|
||||
|
||||
const onStatusDragEnd = () => {
|
||||
draggedStatusId.value = "";
|
||||
dragOverStatusId.value = "";
|
||||
};
|
||||
|
||||
const closeStatusModal = () => {
|
||||
showStatusModal.value = false;
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = {
|
||||
name: "",
|
||||
color: "#7c8596",
|
||||
};
|
||||
};
|
||||
|
||||
const openCreateStatusModal = () => {
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = {
|
||||
name: "",
|
||||
color: "#7c8596",
|
||||
};
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const openEditStatusModal = (status) => {
|
||||
statusMode.value = "edit";
|
||||
editingStatusId.value = status.id;
|
||||
statusForm.value = {
|
||||
name: status.name || "",
|
||||
color: status.color || "#7c8596",
|
||||
};
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const submitStatusForm = () => {
|
||||
const name = statusForm.value.name?.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const color = statusForm.value.color?.trim() || "";
|
||||
|
||||
if (statusMode.value === "create") {
|
||||
emit("create-status", { name, color });
|
||||
} else {
|
||||
if (!editingStatusId.value) {
|
||||
return;
|
||||
}
|
||||
emit("rename-status", {
|
||||
id: editingStatusId.value,
|
||||
name,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
closeStatusModal();
|
||||
};
|
||||
|
||||
const deleteStatusFromModal = () => {
|
||||
if (statusMode.value !== "edit" || !editingStatusId.value) {
|
||||
return;
|
||||
}
|
||||
emit("delete-status", {
|
||||
id: editingStatusId.value,
|
||||
name: statusForm.value.name?.trim() || "",
|
||||
color: statusForm.value.color?.trim() || "",
|
||||
});
|
||||
closeStatusModal();
|
||||
};
|
||||
|
||||
const emitDeleteTaskList = () => {
|
||||
if (!props.selectedTaskList?.id || !props.canDeleteTaskList) {
|
||||
return;
|
||||
}
|
||||
emit("delete-task-list", props.selectedTaskList);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||
|
||||
@@ -5,15 +5,10 @@
|
||||
:tasks="tasks"
|
||||
:statuses="taskStatuses"
|
||||
:selected-task-list="selectedTaskList"
|
||||
:can-delete-task-list="canDeleteTasks"
|
||||
@select-task="emit('select-task', $event)"
|
||||
@filter-change="emit('filter-change', $event)"
|
||||
@reorder-status="emit('reorder-status', $event)"
|
||||
@create-status="emit('create-status', $event)"
|
||||
@rename-status="emit('rename-status', $event)"
|
||||
@delete-status="emit('delete-status', $event)"
|
||||
@update-task-status="emit('update-task-status', $event)"
|
||||
@delete-task-list="emit('delete-task-list', $event)"
|
||||
@edit-task-list="emit('edit-task-list')"
|
||||
/>
|
||||
<SearchResultsPage
|
||||
v-else-if="isSearchRoute"
|
||||
@@ -151,12 +146,8 @@ defineProps({
|
||||
const emit = defineEmits([
|
||||
"select-task",
|
||||
"filter-change",
|
||||
"reorder-status",
|
||||
"create-status",
|
||||
"rename-status",
|
||||
"delete-status",
|
||||
"update-task-status",
|
||||
"delete-task-list",
|
||||
"edit-task-list",
|
||||
"select-note",
|
||||
"select-task-list",
|
||||
"page-change",
|
||||
|
||||
@@ -202,7 +202,6 @@
|
||||
:tasks="tasks"
|
||||
:task-statuses="taskStatuses"
|
||||
:selected-task-list="selectedTaskList"
|
||||
:can-delete-tasks="canDeleteTasks"
|
||||
:is-search-route="isSearchRoute"
|
||||
:search-items="searchItems"
|
||||
:search-query="searchQuery"
|
||||
@@ -220,12 +219,8 @@
|
||||
:is-loading-more-main-notes="spaceStore.notesLoading"
|
||||
@select-task="openTaskDetail"
|
||||
@filter-change="applyTaskFilters"
|
||||
@reorder-status="reorderTaskStatuses"
|
||||
@create-status="createTaskStatus"
|
||||
@rename-status="renameTaskStatus"
|
||||
@delete-status="requestDeleteTaskStatus"
|
||||
@update-task-status="updateTaskStatusFromBoard"
|
||||
@delete-task-list="requestRemoveTaskList"
|
||||
@edit-task-list="showEditTaskListModal = true"
|
||||
@select-note="selectSearchResultNote"
|
||||
@select-task-list="selectTaskList"
|
||||
@page-change="setSearchPage"
|
||||
@@ -319,6 +314,21 @@
|
||||
<div v-if="showUnlockModal" class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
|
||||
<EditTaskListModal
|
||||
v-if="showEditTaskListModal && selectedTaskList"
|
||||
:task-list="selectedTaskList"
|
||||
:statuses="taskStatuses"
|
||||
:category-options="categoryOptions"
|
||||
:can-delete-task-list="canDeleteTasks"
|
||||
@close="showEditTaskListModal = false"
|
||||
@update-task-list="updateTaskListFromModal"
|
||||
@reorder-status="reorderTaskStatuses"
|
||||
@create-status="createTaskStatus"
|
||||
@rename-status="renameTaskStatus"
|
||||
@delete-status="requestDeleteTaskStatus"
|
||||
@delete-task-list="requestRemoveTaskList"
|
||||
/>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showTaskDeleteConfirmModal"
|
||||
:title="taskDeleteConfirmTitle"
|
||||
@@ -339,6 +349,7 @@ import CategoryTree from "../components/CategoryTree.vue";
|
||||
import AppWorkspaceContent from "../components/app/AppWorkspaceContent.vue";
|
||||
import AppModalHost from "../components/app/AppModalHost.vue";
|
||||
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
||||
import EditTaskListModal from "../components/EditTaskListModal.vue";
|
||||
import { sortNotesByPriority } from "../utils/noteSort";
|
||||
import apiClient from "../services/apiClient";
|
||||
|
||||
@@ -354,6 +365,7 @@ const showCreateSpaceModal = ref(false);
|
||||
const showCreateCategoryModal = ref(false);
|
||||
const showCreateNoteModal = ref(false);
|
||||
const showCreateTaskListModal = ref(false);
|
||||
const showEditTaskListModal = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
const navbarRef = ref(null);
|
||||
const navbarHeight = ref(56);
|
||||
@@ -819,6 +831,7 @@ watch(
|
||||
isEditingNote.value = false;
|
||||
selectedCategory.value = null;
|
||||
selectedTaskList.value = taskLists.value.find((list) => list.id === taskListId) || null;
|
||||
await spaceStore.fetchTaskStatuses(currentSpace.value?.id, taskListId);
|
||||
await applyTaskFilters({ taskListId });
|
||||
return;
|
||||
}
|
||||
@@ -1056,6 +1069,7 @@ const selectTaskList = async (taskList) => {
|
||||
showSidebar.value = false;
|
||||
if (currentSpace.value?.id && taskList?.id) {
|
||||
await router.push(dashboardTaskRoute(currentSpace.value.id, taskList.id));
|
||||
await spaceStore.fetchTaskStatuses(currentSpace.value.id, taskList.id);
|
||||
}
|
||||
|
||||
await applyTaskFilters({
|
||||
@@ -1253,22 +1267,22 @@ const createSubtask = (parentTask) => {
|
||||
};
|
||||
|
||||
const createTaskStatus = async (payload) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.createTaskStatus(currentSpace.value.id, payload);
|
||||
await spaceStore.createTaskStatus(currentSpace.value.id, selectedTaskList.value.id, payload);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to create status.");
|
||||
}
|
||||
};
|
||||
|
||||
const renameTaskStatus = async (status) => {
|
||||
if (!currentSpace.value?.id || !status?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.updateTaskStatus(currentSpace.value.id, status.id, {
|
||||
await spaceStore.updateTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id, {
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
});
|
||||
@@ -1300,12 +1314,12 @@ const requestDeleteTaskStatus = (status) => {
|
||||
};
|
||||
|
||||
const deleteTaskStatus = async (status) => {
|
||||
if (!currentSpace.value?.id || !status?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spaceStore.deleteTaskStatus(currentSpace.value.id, status.id);
|
||||
await spaceStore.deleteTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to delete status.");
|
||||
throw error;
|
||||
@@ -1313,11 +1327,11 @@ const deleteTaskStatus = async (status) => {
|
||||
};
|
||||
|
||||
const reorderTaskStatuses = async (orderedIds) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.reorderTaskStatuses(currentSpace.value.id, orderedIds);
|
||||
await spaceStore.reorderTaskStatuses(currentSpace.value.id, selectedTaskList.value.id, orderedIds);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to reorder statuses.");
|
||||
}
|
||||
@@ -1390,10 +1404,25 @@ const createTaskList = async (taskListData) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateTaskListFromModal = async (payload) => {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) return;
|
||||
try {
|
||||
await spaceStore.updateTaskList(currentSpace.value.id, selectedTaskList.value.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
category_id: payload.category_id,
|
||||
});
|
||||
selectedTaskList.value = { ...selectedTaskList.value, name: payload.name, category_id: payload.category_id };
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to update task list.");
|
||||
}
|
||||
};
|
||||
|
||||
const requestRemoveTaskList = (taskList) => {
|
||||
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
|
||||
return;
|
||||
}
|
||||
showEditTaskListModal.value = false;
|
||||
taskDeleteIntent.value = {
|
||||
type: "task-list",
|
||||
payload: taskList,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
const noteLinkedTasks = ref([]);
|
||||
|
||||
const refreshSpaceData = async (spaceId) => {
|
||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
|
||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
|
||||
};
|
||||
|
||||
const fetchSpaces = async () => {
|
||||
@@ -212,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
searchResults.value = [];
|
||||
};
|
||||
|
||||
const fetchTaskStatuses = async (spaceId) => {
|
||||
if (!spaceId) {
|
||||
const fetchTaskStatuses = async (spaceId, taskListId) => {
|
||||
if (!spaceId || !taskListId) {
|
||||
taskStatuses.value = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`);
|
||||
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
|
||||
taskStatuses.value = response.data || [];
|
||||
return taskStatuses.value;
|
||||
} catch (error) {
|
||||
@@ -261,25 +261,25 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
await fetchTaskLists(spaceId);
|
||||
};
|
||||
|
||||
const createTaskStatus = async (spaceId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const createTaskStatus = async (spaceId, taskListId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTaskStatus = async (spaceId, statusId, payload) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTaskStatus = async (spaceId, statusId) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
};
|
||||
|
||||
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, {
|
||||
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
|
||||
ordered_status_ids: orderedStatusIds,
|
||||
});
|
||||
taskStatuses.value = response.data || [];
|
||||
|
||||
Reference in New Issue
Block a user