feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s

This commit is contained in:
domrichardson
2026-03-29 16:14:23 +01:00
parent a1dd2f2c00
commit b9ca845b9c
22 changed files with 1000 additions and 249 deletions

View File

@@ -135,6 +135,7 @@ func main() {
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.UserRepo, db.UserRepo,
permissionService, permissionService,
) )
@@ -151,6 +152,7 @@ func main() {
categoryService := services.NewCategoryService( categoryService := services.NewCategoryService(
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
permissionService, permissionService,
@@ -158,6 +160,7 @@ func main() {
taskService := services.NewTaskService( taskService := services.NewTaskService(
db.TaskRepo, db.TaskRepo,
db.TaskListRepo,
db.TaskStatusRepo, db.TaskStatusRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
@@ -269,6 +272,11 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints // Task endpoints
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")

View File

@@ -430,6 +430,7 @@ type CategoryTreeDTO struct {
*CategoryDTO *CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"` Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"` Notes []*NoteListItemDTO `json:"notes"`
TaskLists []*TaskListDTO `json:"task_lists"`
} }
// NewCategoryDTO creates a DTO from a category entity // NewCategoryDTO creates a DTO from a category entity
@@ -458,7 +459,7 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
type CreateTaskRequest struct { type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"` Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"` Description string `json:"description" validate:"max=2000"`
CategoryID *string `json:"category_id,omitempty"` TaskListID string `json:"task_list_id" validate:"required"`
StatusID string `json:"status_id" validate:"required"` StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"` NoteLinks []string `json:"note_links"`
@@ -468,7 +469,7 @@ type CreateTaskRequest struct {
type UpdateTaskRequest struct { type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"` TaskListID *string `json:"task_list_id,omitempty"`
StatusID *string `json:"status_id,omitempty"` StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"` NoteLinks []string `json:"note_links,omitempty"`
@@ -490,7 +491,7 @@ type TaskDTO struct {
SpaceID string `json:"space_id"` SpaceID string `json:"space_id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
CategoryID *string `json:"category_id,omitempty"` TaskListID string `json:"task_list_id"`
StatusID string `json:"status_id"` StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"` Depth int `json:"depth"`
@@ -538,14 +539,35 @@ type TaskStatusDTO struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// CreateTaskListRequest represents task list creation input.
type CreateTaskListRequest struct {
Name string `json:"name" validate:"required,min=1,max=120"`
Description string `json:"description" validate:"max=500"`
CategoryID *string `json:"category_id,omitempty"`
}
// UpdateTaskListRequest represents task list update input.
type UpdateTaskListRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// TaskListDTO represents a task list in API responses.
type TaskListDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity. // NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO { func NewTaskDTO(task *entities.Task) *TaskDTO {
var categoryID *string
if task.CategoryID != nil {
id := task.CategoryID.Hex()
categoryID = &id
}
var parentTaskID *string var parentTaskID *string
if task.ParentTaskID != nil { if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex() id := task.ParentTaskID.Hex()
@@ -562,7 +584,7 @@ func NewTaskDTO(task *entities.Task) *TaskDTO {
SpaceID: task.SpaceID.Hex(), SpaceID: task.SpaceID.Hex(),
Title: task.Title, Title: task.Title,
Description: task.Description, Description: task.Description,
CategoryID: categoryID, TaskListID: task.TaskListID.Hex(),
StatusID: task.StatusID.Hex(), StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: task.Depth, Depth: task.Depth,
@@ -574,6 +596,27 @@ func NewTaskDTO(task *entities.Task) *TaskDTO {
} }
} }
// NewTaskListDTO creates a DTO from a task list entity.
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
var categoryID *string
if taskList.CategoryID != nil {
id := taskList.CategoryID.Hex()
categoryID = &id
}
return &TaskListDTO{
ID: taskList.ID.Hex(),
SpaceID: taskList.SpaceID.Hex(),
CategoryID: categoryID,
Name: taskList.Name,
Description: taskList.Description,
CreatedBy: taskList.CreatedBy.Hex(),
UpdatedBy: taskList.UpdatedBy.Hex(),
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskStatusDTO creates a DTO from a task status entity. // NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{ return &TaskStatusDTO{

View File

@@ -15,6 +15,7 @@ import (
// CategoryService handles category operations // CategoryService handles category operations
type CategoryService struct { type CategoryService struct {
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
permissionService *PermissionService permissionService *PermissionService
@@ -23,12 +24,14 @@ type CategoryService struct {
// NewCategoryService creates a new category service // NewCategoryService creates a new category service
func NewCategoryService( func NewCategoryService(
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *CategoryService { ) *CategoryService {
return &CategoryService{ return &CategoryService{
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
permissionService: permissionService, permissionService: permissionService,
@@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit
} }
} }
// Get task lists in this category
taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, taskList := range taskLists {
tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList))
}
}
return tree, nil return tree, nil
} }

View File

@@ -17,6 +17,7 @@ type SpaceService struct {
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
userRepo repositories.UserRepository userRepo repositories.UserRepository
permissionService *PermissionService permissionService *PermissionService
} }
@@ -27,6 +28,7 @@ func NewSpaceService(
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *SpaceService { ) *SpaceService {
@@ -35,6 +37,7 @@ func NewSpaceService(
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
userRepo: userRepo, userRepo: userRepo,
permissionService: permissionService, permissionService: permissionService,
} }
@@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err return err
} }
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err return err
} }

View File

@@ -17,6 +17,7 @@ import (
// TaskService handles task and task status operations. // TaskService handles task and task status operations.
type TaskService struct { type TaskService struct {
taskRepo repositories.TaskRepository taskRepo repositories.TaskRepository
taskListRepo repositories.TaskListRepository
taskStatusRepo repositories.TaskStatusRepository taskStatusRepo repositories.TaskStatusRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
@@ -27,6 +28,7 @@ type TaskService struct {
// NewTaskService creates a task service. // NewTaskService creates a task service.
func NewTaskService( func NewTaskService(
taskRepo repositories.TaskRepository, taskRepo repositories.TaskRepository,
taskListRepo repositories.TaskListRepository,
taskStatusRepo repositories.TaskStatusRepository, taskStatusRepo repositories.TaskStatusRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
@@ -35,6 +37,7 @@ func NewTaskService(
) *TaskService { ) *TaskService {
return &TaskService{ return &TaskService{
taskRepo: taskRepo, taskRepo: taskRepo,
taskListRepo: taskListRepo,
taskStatusRepo: taskStatusRepo, taskStatusRepo: taskStatusRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
@@ -121,13 +124,10 @@ func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
return result, nil return result, nil
} }
func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error { func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error {
if categoryID == nil { taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
return nil if err != nil || taskList.SpaceID != spaceID {
} return errors.New("invalid task list")
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
if err != nil || category.SpaceID != spaceID {
return errors.New("invalid category")
} }
return nil return nil
} }
@@ -209,14 +209,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
categoryID, err := toObjectIDPtr(req.CategoryID)
if err != nil {
return nil, errors.New("invalid category")
}
if err := s.validateCategory(ctx, spaceID, categoryID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID) parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil { if err != nil {
return nil, errors.New("invalid parent task") return nil, errors.New("invalid parent task")
@@ -226,6 +218,14 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
taskListID, err := bson.ObjectIDFromHex(strings.TrimSpace(req.TaskListID))
if err != nil {
return nil, errors.New("invalid task list")
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -258,11 +258,19 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
statusID = parsedStatusID statusID = parsedStatusID
} }
if parentTaskID != nil {
parent, parentErr := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
if parentErr != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
taskListID = parent.TaskListID
}
task := &entities.Task{ task := &entities.Task{
SpaceID: spaceID, SpaceID: spaceID,
Title: strings.TrimSpace(req.Title), Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
CategoryID: categoryID, TaskListID: taskListID,
StatusID: statusID, StatusID: statusID,
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: depth, Depth: depth,
@@ -318,7 +326,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
func (s *TaskService) ListTasks( func (s *TaskService) ListTasks(
ctx context.Context, ctx context.Context,
spaceID, userID bson.ObjectID, spaceID, userID bson.ObjectID,
categoryID, statusID, parentTaskID *string, taskListID, statusID, parentTaskID *string,
) ([]*dto.TaskDTO, error) { ) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
@@ -328,12 +336,12 @@ func (s *TaskService) ListTasks(
} }
filters := map[string]any{} filters := map[string]any{}
if categoryID != nil && strings.TrimSpace(*categoryID) != "" { if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
id, err := bson.ObjectIDFromHex(*categoryID) id, err := bson.ObjectIDFromHex(*taskListID)
if err != nil { if err != nil {
return nil, errors.New("invalid category filter") return nil, errors.New("invalid task list filter")
} }
filters["category_id"] = id filters["task_list_id"] = id
} }
if statusID != nil && strings.TrimSpace(*statusID) != "" { if statusID != nil && strings.TrimSpace(*statusID) != "" {
id, err := bson.ObjectIDFromHex(*statusID) id, err := bson.ObjectIDFromHex(*statusID)
@@ -453,19 +461,21 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
task.Description = strings.TrimSpace(*req.Description) task.Description = strings.TrimSpace(*req.Description)
} }
if req.CategoryID != nil { if req.TaskListID != nil {
if strings.TrimSpace(*req.CategoryID) == "" { if strings.TrimSpace(*req.TaskListID) == "" {
task.CategoryID = nil return nil, errors.New("task list is required")
} else {
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
task.CategoryID = &categoryID
} }
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil { taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID)
if parseErr != nil {
return nil, errors.New("invalid task list")
}
if task.ParentTaskID != nil {
return nil, errors.New("subtasks inherit task list from parent")
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err return nil, err
} }
task.TaskListID = taskListID
} }
if req.ParentTaskID != nil { if req.ParentTaskID != nil {
@@ -486,6 +496,11 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
} }
task.ParentTaskID = &parentID task.ParentTaskID = &parentID
task.Depth = depth task.Depth = depth
parentTask, parentErr := s.taskRepo.GetTaskByID(ctx, parentID)
if parentErr != nil || parentTask.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
task.TaskListID = parentTask.TaskListID
} }
} }
@@ -670,6 +685,144 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n
return dto.NewTaskDTO(task), nil return dto.NewTaskDTO(task), nil
} }
func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskListDTO, 0, len(lists))
for _, list := range lists {
result = append(result, dto.NewTaskListDTO(list))
}
return result, nil
}
func (s *TaskService) CreateTaskList(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
var categoryID *bson.ObjectID
if req.CategoryID != nil && strings.TrimSpace(*req.CategoryID) != "" {
id, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, id)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
categoryID = &id
}
list := &entities.TaskList{
SpaceID: spaceID,
CategoryID: categoryID,
Name: name,
Description: strings.TrimSpace(req.Description),
CreatedBy: userID,
UpdatedBy: userID,
}
if err := s.taskListRepo.CreateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) UpdateTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.UpdateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return nil, errors.New("task list not found")
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
list.Name = name
}
if req.Description != nil {
list.Description = strings.TrimSpace(*req.Description)
}
if req.CategoryID != nil {
if strings.TrimSpace(*req.CategoryID) == "" {
list.CategoryID = nil
} else {
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
list.CategoryID = &categoryID
}
}
list.UpdatedBy = userID
if err := s.taskListRepo.UpdateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return errors.New("task list not found")
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID})
if err != nil {
return err
}
if len(tasks) > 0 {
return errors.New("cannot delete task list with tasks")
}
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, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err

View File

@@ -14,7 +14,7 @@ type Task struct {
SpaceID bson.ObjectID `bson:"space_id"` SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"` Title string `bson:"title"`
Description string `bson:"description"` Description string `bson:"description"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"` TaskListID bson.ObjectID `bson:"task_list_id"`
StatusID bson.ObjectID `bson:"status_id"` StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"` Depth int `bson:"depth"`
@@ -35,3 +35,16 @@ type TaskStatus struct {
CreatedAt time.Time `bson:"created_at"` CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskList groups tasks under a named list that can be attached to a category.
type TaskList struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}

View File

@@ -229,6 +229,17 @@ type TaskRepository interface {
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
} }
// TaskListRepository defines task list operations.
type TaskListRepository interface {
CreateTaskList(ctx context.Context, list *entities.TaskList) error
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// TaskStatusRepository defines task status operations // TaskStatusRepository defines task status operations
type TaskStatusRepository interface { type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error CreateStatus(ctx context.Context, status *entities.TaskStatus) error

View File

@@ -16,6 +16,7 @@ type Database struct {
MembershipRepo *MembershipRepository MembershipRepo *MembershipRepository
NoteRepo *NoteRepository NoteRepo *NoteRepository
CategoryRepo *CategoryRepository CategoryRepo *CategoryRepository
TaskListRepo *TaskListRepository
TaskRepo *TaskRepository TaskRepo *TaskRepository
TaskStatusRepo *TaskStatusRepository TaskStatusRepo *TaskStatusRepository
RevisionRepo *NoteRevisionRepository RevisionRepo *NoteRevisionRepository
@@ -49,6 +50,7 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
MembershipRepo: NewMembershipRepository(db), MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db), NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db), CategoryRepo: NewCategoryRepository(db),
TaskListRepo: NewTaskListRepository(db),
TaskRepo: NewTaskRepository(db), TaskRepo: NewTaskRepository(db),
TaskStatusRepo: NewTaskStatusRepository(db), TaskStatusRepo: NewTaskStatusRepository(db),
RevisionRepo: NewNoteRevisionRepository(db), RevisionRepo: NewNoteRevisionRepository(db),
@@ -87,6 +89,9 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }

View File

@@ -108,13 +108,95 @@ func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
}) })
return err return err
} }
// TaskListRepository implements task list data access.
type TaskListRepository struct {
collection *mongo.Collection
}
// NewTaskListRepository creates a new task list repository.
func NewTaskListRepository(db *mongo.Database) *TaskListRepository {
return &TaskListRepository{collection: db.Collection("task_lists")}
}
func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error {
list.ID = bson.NewObjectID()
list.CreatedAt = time.Now()
list.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, list)
return err
}
func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) {
var list entities.TaskList
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task list not found")
}
return nil, err
}
return &list, nil
}
func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error {
list.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list)
return err
}
func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskListRepository) 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}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}},
})
return err
}
// TaskStatusRepository implements task status data access. // TaskStatusRepository implements task status data access.
type TaskStatusRepository struct { type TaskStatusRepository struct {
collection *mongo.Collection collection *mongo.Collection

View File

@@ -64,15 +64,15 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId")) taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
statusID := strings.TrimSpace(r.URL.Query().Get("statusId")) statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
categoryFilter := &categoryID taskListFilter := &taskListID
statusFilter := &statusID statusFilter := &statusID
parentFilter := &parentTaskID parentFilter := &parentTaskID
if categoryID == "" { if taskListID == "" {
categoryFilter = nil taskListFilter = nil
} }
if statusID == "" { if statusID == "" {
statusFilter = nil statusFilter = nil
@@ -81,7 +81,7 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
parentFilter = nil parentFilter = nil
} }
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter) tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -91,6 +91,94 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(tasks) json.NewEncoder(w).Encode(tasks)
} }
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lists)
}
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
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.UpdateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
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
}
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) { func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r) userID, spaceID, err := parseIDsFromRequest(r)
if err != nil { if err != nil {

View File

@@ -44,7 +44,7 @@
<!-- Search --> <!-- Search -->
<div class="search-box nav-search" v-if="!isAdminRoute"> <div class="search-box nav-search" v-if="!isAdminRoute">
<input type="text" class="form-control" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" /> <input type="text" class="form-control" placeholder="Search notes & task lists..." v-model="searchQuery" @keyup.enter="performSearch" />
</div> </div>
<!-- Theme Toggle --> <!-- Theme Toggle -->
@@ -98,6 +98,7 @@
:on-add-subcategory="openCreateSubcategoryModal" :on-add-subcategory="openCreateSubcategoryModal"
:on-edit-category="openEditCategoryModal" :on-edit-category="openEditCategoryModal"
:on-delete-category="removeCategory" :on-delete-category="removeCategory"
:on-select-task-list="selectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -128,9 +129,36 @@
</h5> </h5>
</div> </div>
<div class="col-auto d-flex align-items-center"> <div class="col-auto d-flex align-items-center">
<div class="btn-group me-2" role="group" aria-label="Workspace mode"> <div
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button> v-if="(activeView === 'notes' || activeView === 'tasks') && (canCreateNotes || canCreateTasks)"
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button> ref="createDropdownRef"
class="dropdown me-2"
@mouseleave="showCreateMenu = false"
>
<button class="btn btn-primary dropdown-toggle action-button" type="button" aria-label="Create" title="Create" @click="toggleCreateMenu">
<i class="mdi mdi-plus-circle-outline me-1" aria-hidden="true"></i>
<span class="action-label">Create</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: showCreateMenu }">
<li v-if="activeView === 'notes' && canCreateNotes">
<button class="dropdown-item" type="button" @click="openCreateNoteModalFromMenu">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
New Note
</button>
</li>
<li v-if="activeView === 'notes' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openCreateTaskListModalFromMenu">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
New Task List
</button>
</li>
<li v-if="activeView === 'tasks' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</li>
</ul>
</div> </div>
<div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode"> <div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button <button
@@ -174,14 +202,6 @@
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span> <span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
</button> </button>
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Note</span>
</button>
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Task</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -192,8 +212,6 @@
v-if="activeView === 'tasks'" v-if="activeView === 'tasks'"
:tasks="tasks" :tasks="tasks"
:statuses="taskStatuses" :statuses="taskStatuses"
:category-options="categoryOptions"
@create-task="openTaskCreateModal"
@select-task="openTaskDetail" @select-task="openTaskDetail"
@filter-change="applyTaskFilters" @filter-change="applyTaskFilters"
@reorder-status="reorderTaskStatuses" @reorder-status="reorderTaskStatuses"
@@ -204,12 +222,13 @@
/> />
<SearchResultsPage <SearchResultsPage
v-else-if="isSearchRoute" v-else-if="isSearchRoute"
:notes="searchResults" :items="searchItems"
:query="searchQuery" :query="searchQuery"
:current-page="searchPage" :current-page="searchPage"
:page-size="searchPageSize" :page-size="searchPageSize"
:view-mode="noteViewMode" :view-mode="noteViewMode"
@select-note="selectSearchResultNote" @select-note="selectSearchResultNote"
@select-task-list="selectTaskList"
@page-change="setSearchPage" @page-change="setSearchPage"
/> />
<NoteEditor <NoteEditor
@@ -231,13 +250,14 @@
:linked-tasks="linkedTasksForSelectedNote" :linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="openLinkedTaskFromNote" @open-linked-task="openLinkedTaskFromNote"
/> />
<NoteList <WorkspaceList
v-else v-else
:notes="displayedNotes" :items="displayedItems"
:can-load-more="canLoadMoreMainNotes" :can-load-more="canLoadMoreMainNotes"
:is-loading-more="spaceStore.notesLoading" :is-loading-more="spaceStore.notesLoading"
:view-mode="noteViewMode" :view-mode="noteViewMode"
@select-note="selectNote" @select-note="selectNote"
@select-task-list="selectTaskList"
@load-more="loadMoreMainNotes" @load-more="loadMoreMainNotes"
/> />
</div> </div>
@@ -279,6 +299,13 @@
@submit="submitCategory" @submit="submitCategory"
/> />
<CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" /> <CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" />
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategory?.id || null"
@close="showCreateTaskListModal = false"
@create="createTaskList"
/>
<SpaceSettingsModal <SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings" v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace" :space="currentSpace"
@@ -290,7 +317,6 @@
v-if="showTaskModal" v-if="showTaskModal"
:task="taskModalDraft || {}" :task="taskModalDraft || {}"
:statuses="taskStatuses" :statuses="taskStatuses"
:category-options="categoryOptions"
:parent-task-options="taskParentOptions" :parent-task-options="taskParentOptions"
:subtasks="taskDetail?.subtasks || []" :subtasks="taskDetail?.subtasks || []"
@close="showTaskModal = false" @close="showTaskModal = false"
@@ -342,11 +368,12 @@ import { useSpaceStore } from "./stores/spaceStore";
import CategoryTree from "./components/CategoryTree.vue"; import CategoryTree from "./components/CategoryTree.vue";
import NoteEditor from "./components/NoteEditor.vue"; import NoteEditor from "./components/NoteEditor.vue";
import NoteViewer from "./components/NoteViewer.vue"; import NoteViewer from "./components/NoteViewer.vue";
import NoteList from "./components/NoteList.vue"; import WorkspaceList from "./components/WorkspaceList.vue";
import SearchResultsPage from "./components/SearchResultsPage.vue"; import SearchResultsPage from "./components/SearchResultsPage.vue";
import CreateSpaceModal from "./components/CreateSpaceModal.vue"; import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue"; import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue"; import CreateNoteModal from "./components/CreateNoteModal.vue";
import CreateTaskListModal from "./components/CreateTaskListModal.vue";
import SpaceSettingsModal from "./components/SpaceSettingsModal.vue"; import SpaceSettingsModal from "./components/SpaceSettingsModal.vue";
import TaskBoard from "./components/TaskBoard.vue"; import TaskBoard from "./components/TaskBoard.vue";
import TaskDetailModal from "./components/TaskDetailModal.vue"; import TaskDetailModal from "./components/TaskDetailModal.vue";
@@ -363,12 +390,14 @@ const showUserMenu = ref(false);
const showCreateSpaceModal = ref(false); const showCreateSpaceModal = ref(false);
const showCreateCategoryModal = ref(false); const showCreateCategoryModal = ref(false);
const showCreateNoteModal = ref(false); const showCreateNoteModal = ref(false);
const showCreateTaskListModal = ref(false);
const showSpaceSettingsModal = ref(false); const showSpaceSettingsModal = ref(false);
const showSidebar = ref(false); const showSidebar = ref(false);
const navbarRef = ref(null); const navbarRef = ref(null);
const navbarHeight = ref(56); const navbarHeight = ref(56);
const spaceDropdownRef = ref(null); const spaceDropdownRef = ref(null);
const userDropdownRef = ref(null); const userDropdownRef = ref(null);
const createDropdownRef = ref(null);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedNote = ref(null); const selectedNote = ref(null);
const selectedCategory = ref(null); const selectedCategory = ref(null);
@@ -393,14 +422,16 @@ const unlockError = ref("");
const unlockingNote = ref(false); const unlockingNote = ref(false);
const activeView = ref("notes"); const activeView = ref("notes");
const taskFilters = ref({ const taskFilters = ref({
categoryId: null, taskListId: null,
statusId: null, statusId: null,
parentTaskId: null, parentTaskId: null,
}); });
const selectedTaskList = ref(null);
const showTaskModal = ref(false); const showTaskModal = ref(false);
const taskDetail = ref(null); const taskDetail = ref(null);
const taskModalDraft = ref(null); const taskModalDraft = ref(null);
const linkedTasksForSelectedNote = ref([]); const linkedTasksForSelectedNote = ref([]);
const showCreateMenu = ref(false);
const currentUser = computed(() => authStore.user); const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin"); const isAdminRoute = computed(() => route.path === "/admin");
@@ -410,6 +441,19 @@ const isAuthRoute = computed(() => route.path === "/login" || route.path === "/r
const spaces = computed(() => spaceStore.spaces); const spaces = computed(() => spaceStore.spaces);
const currentSpace = computed(() => spaceStore.currentSpace); const currentSpace = computed(() => spaceStore.currentSpace);
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults)); const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
const searchItems = computed(() => {
const query = searchQuery.value.toLowerCase();
const noteItems = searchResults.value.map((note) => ({
...note,
kind: "note",
}));
const matchingTaskLists = (spaceStore.taskLists || []).filter((tl) => tl.name.toLowerCase().includes(query));
const taskListItems = matchingTaskLists.map((tl) => ({
...tl,
kind: "task-list",
}));
return [...taskListItems, ...noteItems];
});
const searchPageSize = 12; const searchPageSize = 12;
const searchPage = computed(() => { const searchPage = computed(() => {
const pageValue = Number.parseInt(route.query.page || "1", 10); const pageValue = Number.parseInt(route.query.page || "1", 10);
@@ -469,13 +513,46 @@ const collectNotesFromCategory = (category, bucket = []) => {
return bucket; return bucket;
}; };
const collectTaskListsFromCategory = (category, bucket = []) => {
if (!category) {
return bucket;
}
if (Array.isArray(category.task_lists) && category.task_lists.length) {
bucket.push(...category.task_lists);
}
for (const subcategory of category.subcategories || []) {
collectTaskListsFromCategory(subcategory, bucket);
}
return bucket;
};
const displayedNotes = computed(() => { const displayedNotes = computed(() => {
if (!selectedCategory.value) { if (!selectedCategory.value) {
return sortNotesByPriority(spaceStore.notes); return sortNotesByPriority(spaceStore.notes);
} }
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, [])); return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, []));
}); });
const displayedTaskLists = computed(() => {
if (!selectedCategory.value) {
return [...(spaceStore.taskLists || [])].sort((left, right) => left.name.localeCompare(right.name));
}
return [...collectTaskListsFromCategory(selectedCategory.value, [])].sort((left, right) => left.name.localeCompare(right.name));
});
const displayedItems = computed(() => {
const taskListItems = displayedTaskLists.value.map((taskList) => ({
...taskList,
kind: "task-list",
}));
const noteItems = displayedNotes.value.map((note) => ({
...note,
kind: "note",
}));
return [...taskListItems, ...noteItems];
});
const tasks = computed(() => spaceStore.tasks || []); const tasks = computed(() => spaceStore.tasks || []);
const taskLists = computed(() => spaceStore.taskLists || []);
const taskStatuses = computed(() => spaceStore.taskStatuses || []); const taskStatuses = computed(() => spaceStore.taskStatuses || []);
const initialTaskStatusId = computed(() => { const initialTaskStatusId = computed(() => {
if (!taskStatuses.value.length) { if (!taskStatuses.value.length) {
@@ -518,6 +595,13 @@ const openSpaceHome = () => {
unlockPassword.value = ""; unlockPassword.value = "";
unlockError.value = ""; unlockError.value = "";
searchQuery.value = ""; searchQuery.value = "";
selectedTaskList.value = null;
activeView.value = "notes";
taskFilters.value = {
taskListId: null,
statusId: null,
parentTaskId: null,
};
spaceStore.clearSearchResults(); spaceStore.clearSearchResults();
if (route.path !== "/") { if (route.path !== "/") {
router.push("/"); router.push("/");
@@ -580,6 +664,27 @@ const breadcrumbItems = computed(() => {
return items; return items;
} }
if (activeView.value === "tasks" && selectedTaskList.value) {
const taskListCategoryId = selectedTaskList.value.category_id;
if (taskListCategoryId) {
const categoryTrail = findCategoryPath(categoryTree.value, taskListCategoryId) || [];
for (const category of categoryTrail) {
items.push({
label: category.name,
clickable: true,
onClick: () => openCategoryFromBreadcrumb(category),
});
}
}
items.push({
label: selectedTaskList.value.name,
clickable: false,
onClick: null,
});
return items;
}
if (selectedCategory.value) { if (selectedCategory.value) {
const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value]; const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value];
for (let i = 0; i < categoryTrail.length; i++) { for (let i = 0; i < categoryTrail.length; i++) {
@@ -710,6 +815,14 @@ const toggleUserMenu = () => {
} }
}; };
const toggleCreateMenu = () => {
showCreateMenu.value = !showCreateMenu.value;
if (showCreateMenu.value) {
showSpaceDropdown.value = false;
showUserMenu.value = false;
}
};
const handleDocumentClick = (event) => { const handleDocumentClick = (event) => {
const target = event.target; const target = event.target;
@@ -720,6 +833,10 @@ const handleDocumentClick = (event) => {
if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) { if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) {
showUserMenu.value = false; showUserMenu.value = false;
} }
if (showCreateMenu.value && createDropdownRef.value && !createDropdownRef.value.contains(target)) {
showCreateMenu.value = false;
}
}; };
const handleEscapeKey = (event) => { const handleEscapeKey = (event) => {
@@ -728,6 +845,7 @@ const handleEscapeKey = (event) => {
} }
showSpaceDropdown.value = false; showSpaceDropdown.value = false;
showUserMenu.value = false; showUserMenu.value = false;
showCreateMenu.value = false;
showSidebar.value = false; showSidebar.value = false;
}; };
@@ -737,7 +855,9 @@ const selectSpace = async (space) => {
localStorage.setItem("currentSpaceId", space.id); localStorage.setItem("currentSpaceId", space.id);
selectedNote.value = null; selectedNote.value = null;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
linkedTasksForSelectedNote.value = []; linkedTasksForSelectedNote.value = [];
await applyTaskFilters(taskFilters.value); await applyTaskFilters(taskFilters.value);
}; };
@@ -782,8 +902,13 @@ const selectNote = async (note) => {
showUnlockModal.value = true; showUnlockModal.value = true;
selectedNote.value = null; selectedNote.value = null;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
return; return;
} }
@@ -795,8 +920,13 @@ const selectNote = async (note) => {
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`); const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`);
selectedNote.value = response.data; selectedNote.value = response.data;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
} catch { } catch {
alert("Unable to load note content."); alert("Unable to load note content.");
} }
@@ -828,7 +958,9 @@ const unlockProtectedNote = async () => {
}); });
selectedNote.value = response.data; selectedNote.value = response.data;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
closeUnlockModal(); closeUnlockModal();
} catch (error) { } catch (error) {
@@ -840,11 +972,31 @@ const unlockProtectedNote = async () => {
const selectCategory = (category) => { const selectCategory = (category) => {
selectedCategory.value = category; selectedCategory.value = category;
selectedTaskList.value = null;
selectedNote.value = null; selectedNote.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
}; };
const selectTaskList = async (taskList) => {
selectedTaskList.value = taskList;
selectedCategory.value = null;
selectedNote.value = null;
isEditingNote.value = false;
activeView.value = "tasks";
showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
await applyTaskFilters({
...taskFilters.value,
taskListId: taskList?.id || null,
});
};
const performSearch = async () => { const performSearch = async () => {
const q = searchQuery.value.trim(); const q = searchQuery.value.trim();
if (!q) { if (!q) {
@@ -903,22 +1055,30 @@ const loadMoreMainNotes = async () => {
}; };
const applyTaskFilters = async (filters) => { const applyTaskFilters = async (filters) => {
taskFilters.value = filters; taskFilters.value = {
...taskFilters.value,
...filters,
taskListId: selectedTaskList.value?.id || taskFilters.value.taskListId || null,
};
if (!currentSpace.value?.id) { if (!currentSpace.value?.id) {
return; return;
} }
await spaceStore.fetchTasks(currentSpace.value.id, filters); await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
}; };
const openTaskCreateModal = () => { const openTaskCreateModal = () => {
if (!canCreateTasks.value) { if (!canCreateTasks.value) {
return; return;
} }
if (!selectedTaskList.value?.id) {
alert("Select a task list first.");
return;
}
taskDetail.value = null; taskDetail.value = null;
taskModalDraft.value = { taskModalDraft.value = {
title: "", title: "",
description: "", description: "",
category_id: selectedCategory.value?.id || null, task_list_id: selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value, status_id: initialTaskStatusId.value,
parent_task_id: null, parent_task_id: null,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1010,7 +1170,7 @@ const createSubtask = (parentTask) => {
taskModalDraft.value = { taskModalDraft.value = {
title: "", title: "",
description: "", description: "",
category_id: parentTask.category_id || null, task_list_id: parentTask.task_list_id || selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value, status_id: initialTaskStatusId.value,
parent_task_id: parentTask.id, parent_task_id: parentTask.id,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1102,10 +1262,38 @@ const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatus
}; };
const openLinkedTaskFromNote = async (task) => { const openLinkedTaskFromNote = async (task) => {
selectedTaskList.value = taskLists.value.find((list) => list.id === task.task_list_id) || null;
selectedNote.value = null;
await applyTaskFilters({ taskListId: selectedTaskList.value?.id || null });
activeView.value = "tasks"; activeView.value = "tasks";
await openTaskDetail(task); await openTaskDetail(task);
}; };
const openCreateNoteModalFromMenu = () => {
showCreateMenu.value = false;
showCreateNoteModal.value = true;
};
const openCreateTaskListModalFromMenu = () => {
showCreateMenu.value = false;
showCreateTaskListModal.value = true;
};
const createTaskList = async (taskListData) => {
if (!currentSpace.value?.id || !canCreateTasks.value) {
showCreateTaskListModal.value = false;
return;
}
try {
const created = await spaceStore.createTaskList(currentSpace.value.id, taskListData);
showCreateTaskListModal.value = false;
await selectTaskList(created);
} catch (error) {
alert(error?.response?.data || "Unable to create task list.");
}
};
const createSpace = async (spaceData) => { const createSpace = async (spaceData) => {
showCreateSpaceModal.value = false; showCreateSpaceModal.value = false;
await spaceStore.createSpace(spaceData); await spaceStore.createSpace(spaceData);
@@ -1259,6 +1447,3 @@ const logout = () => {
</script> </script>
<style scoped src="./assets/styles/scoped/App.css"></style> <style scoped src="./assets/styles/scoped/App.css"></style>

View File

@@ -90,6 +90,27 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.task-list-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.95rem;
margin-bottom: 0.25rem;
color: #2f3d52;
background: #f7f9ff;
border: 1px solid #d9e3ff;
}
.task-list-item:hover {
background: #e9efff;
}
.task-list-item span {
flex-grow: 1;
}
.note-item:hover { .note-item:hover {
background-color: #e9ecef; background-color: #e9ecef;
} }
@@ -120,12 +141,12 @@
} }
.note-item.is-featured { .note-item.is-featured {
background: var(--color-surface)9db; background: #fff9db;
border: 1px solid #ffd8a8; border: 1px solid #ffd8a8;
} }
.note-item.is-featured:hover { .note-item.is-featured:hover {
background: var(--color-surface)6c5; background: #fff3c5;
} }
.subcategories { .subcategories {
@@ -163,6 +184,16 @@
background-color: var(--color-surface-muted); background-color: var(--color-surface-muted);
} }
:root[data-bs-theme="dark"] .task-list-item {
background: #1f2a44;
border-color: #334b7d;
color: #bfceef;
}
:root[data-bs-theme="dark"] .task-list-item:hover {
background: #26365b;
}
:root[data-bs-theme="dark"] .note-item.is-pinned { :root[data-bs-theme="dark"] .note-item.is-pinned {
background: #1a3a5c; background: #1a3a5c;
border-color: #2d6a9f; border-color: #2d6a9f;
@@ -180,5 +211,3 @@
:root[data-bs-theme="dark"] .note-item.is-featured:hover { :root[data-bs-theme="dark"] .note-item.is-featured:hover {
background: #453710; background: #453710;
} }

View File

@@ -13,7 +13,7 @@
.task-filters { .task-filters {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
@@ -292,7 +292,7 @@
.danger-zone { .danger-zone {
border: 1px solid #f3b5b5; border: 1px solid #f3b5b5;
border-radius: 0.75rem; border-radius: 0.75rem;
background: var(--color-surface)5f5; background: var(--color-surface) 5f5;
padding: 0.75rem; padding: 0.75rem;
} }
@@ -399,5 +399,3 @@
:root[data-bs-theme="dark"] .empty-state { :root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8; color: #7a8fa8;
} }

View File

@@ -1,10 +1,10 @@
.note-list { .workspace-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem; gap: 1rem;
} }
.empty-notes-state { .empty-workspace-state {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 48vh; min-height: 48vh;
display: flex; display: flex;
@@ -18,41 +18,42 @@
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
} }
.empty-notes-icon { .empty-workspace-icon {
font-size: 5.25rem; font-size: 5.25rem;
line-height: 1; line-height: 1;
color: #60789a; color: #60789a;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.empty-notes-title { .empty-workspace-title {
margin: 0; margin: 0;
color: #23364f; color: #23364f;
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 700; font-weight: 700;
} }
.empty-notes-message { .empty-workspace-message {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
max-width: 460px; max-width: 460px;
color: #4f637d; color: #4f637d;
font-size: 1.05rem; font-size: 1.05rem;
} }
.note-card { .content-card {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
background: var(--color-surface);
} }
.note-card:hover { .content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px); transform: translateY(-2px);
} }
.note-title { .content-title {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--color-text); color: var(--color-text);
display: flex; display: flex;
@@ -60,6 +61,14 @@
gap: 0.3rem; gap: 0.3rem;
} }
.content-preview {
color: #666;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin-icon { .pin-icon {
color: #408aca; color: #408aca;
font-size: 0.9em; font-size: 0.9em;
@@ -72,22 +81,25 @@
flex-shrink: 0; flex-shrink: 0;
} }
.note-card.is-pinned { .list-icon {
color: #5568a8;
font-size: 1rem;
flex-shrink: 0;
}
.content-card.is-pinned {
background: #dbf5ff; background: #dbf5ff;
border-color: #a8d1ff; border-color: #a8d1ff;
} }
.note-card.is-featured { .content-card.is-featured {
border-color: #ffd8a8; border-color: #ffd8a8;
background: var(--color-surface)9db; background: #fff9db;
} }
.note-preview { .content-card.is-task-list {
color: #666; border-color: #d9e3ff;
margin-bottom: 0.5rem; background: #f7f9ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.list-footer { .list-footer {
@@ -97,14 +109,13 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* List view overrides */ .workspace-list--list {
.note-list--list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.note-list--list .note-card { .workspace-list--list .content-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -112,7 +123,7 @@
border-radius: 6px; border-radius: 6px;
} }
.note-list--list .note-card:hover { .workspace-list--list .content-card:hover {
transform: none; transform: none;
box-shadow: none; box-shadow: none;
background-color: #eef2ff; background-color: #eef2ff;
@@ -120,7 +131,7 @@
border-left: 3px solid var(--color-primary); border-left: 3px solid var(--color-primary);
} }
.note-list--list .note-title { .workspace-list--list .content-title {
flex: 0 0 220px; flex: 0 0 220px;
margin-bottom: 0; margin-bottom: 0;
white-space: nowrap; white-space: nowrap;
@@ -128,80 +139,82 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.note-list--list .note-preview { .workspace-list--list .content-preview {
flex: 1; flex: 1;
margin-bottom: 0; margin-bottom: 0;
} }
.note-list--list .note-card > small { .workspace-list--list .content-card > small {
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap; white-space: nowrap;
} }
.note-list--list .list-footer {
grid-column: unset;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.empty-notes-state { .empty-workspace-state {
min-height: 40vh; min-height: 40vh;
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
} }
.empty-notes-icon { .empty-workspace-icon {
font-size: 4.3rem; font-size: 4.3rem;
} }
.empty-notes-title { .empty-workspace-title {
font-size: 1.45rem; font-size: 1.45rem;
} }
} }
/* Dark mode overrides */ :root[data-bs-theme="dark"] .empty-workspace-state {
:root[data-bs-theme="dark"] .empty-notes-state {
border-color: var(--color-border); border-color: var(--color-border);
background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%); background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%);
} }
:root[data-bs-theme="dark"] .empty-notes-title { :root[data-bs-theme="dark"] .empty-workspace-title {
color: var(--color-text); color: var(--color-text);
} }
:root[data-bs-theme="dark"] .empty-notes-message { :root[data-bs-theme="dark"] .empty-workspace-message {
color: #94a3b8; color: #94a3b8;
} }
:root[data-bs-theme="dark"] .note-card { :root[data-bs-theme="dark"] .content-card {
border-color: var(--color-border); border-color: var(--color-border);
background-color: var(--color-surface); background-color: var(--color-surface);
} }
:root[data-bs-theme="dark"] .note-card:hover { :root[data-bs-theme="dark"] .content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
} }
:root[data-bs-theme="dark"] .note-list--list .note-card:hover { :root[data-bs-theme="dark"] .workspace-list--list .content-card:hover {
background-color: #2a2f3a; background-color: #2a2f3a;
border-color: #7aa2f7; border-color: #7aa2f7;
border-left-color: #7aa2f7; border-left-color: #7aa2f7;
} }
:root[data-bs-theme="dark"] .note-title { :root[data-bs-theme="dark"] .content-title {
color: var(--color-text); color: var(--color-text);
} }
:root[data-bs-theme="dark"] .note-preview { :root[data-bs-theme="dark"] .content-preview {
color: #94a3b8; color: #94a3b8;
} }
:root[data-bs-theme="dark"] .note-card.is-pinned { :root[data-bs-theme="dark"] .content-card.is-pinned {
background: #1a3a5c; background: #1a3a5c;
border-color: #2d6a9f; border-color: #2d6a9f;
} }
:root[data-bs-theme="dark"] .note-card.is-featured { :root[data-bs-theme="dark"] .content-card.is-featured {
background: #3a2e0a; background: #3a2e0a;
border-color: #7a5a0a; border-color: #7a5a0a;
} }
:root[data-bs-theme="dark"] .content-card.is-task-list {
background: #1f2a44;
border-color: #334b7d;
}
:root[data-bs-theme="dark"] .list-icon {
color: #bfceef;
}

View File

@@ -2,7 +2,7 @@
<div class="category-tree"> <div class="category-tree">
<div v-for="category in categories" :key="category.id" class="category-item"> <div v-for="category in categories" :key="category.id" class="category-item">
<div class="category-header" @click="handleCategoryClick(category)"> <div class="category-header" @click="handleCategoryClick(category)">
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length"> <span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i> <i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
</span> </span>
<span v-else class="expand-icon"> </span> <span v-else class="expand-icon"> </span>
@@ -20,6 +20,11 @@
</div> </div>
<div v-if="expandedCategories[category.id]" class="category-content"> <div v-if="expandedCategories[category.id]" class="category-content">
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
<span>{{ taskList.name }}</span>
</div>
<div <div
v-for="note in sortedNotes(category.notes)" v-for="note in sortedNotes(category.notes)"
:key="note.id" :key="note.id"
@@ -41,6 +46,7 @@
:on-add-subcategory="onAddSubcategory" :on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory" :on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory" :on-delete-category="onDeleteCategory"
:on-select-task-list="onSelectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -80,6 +86,10 @@ const props = defineProps({
type: Function, type: Function,
required: true, required: true,
}, },
onSelectTaskList: {
type: Function,
required: true,
},
canCreateCategories: { canCreateCategories: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -142,6 +152,3 @@ const handleDeleteCategory = (category) => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style> <style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>

View File

@@ -0,0 +1,84 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Task List</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleCreate">
<div class="mb-3">
<label for="taskListName" class="form-label">Task List Name</label>
<input id="taskListName" v-model="form.name" type="text" class="form-control" maxlength="120" required />
</div>
<div class="mb-3">
<label for="taskListCategory" class="form-label">Category</label>
<select id="taskListCategory" v-model="form.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
categoryOptions: {
type: Array,
default: () => [],
},
defaultCategoryId: {
type: String,
default: null,
},
});
const emit = defineEmits(["close", "create"]);
const form = ref({
name: "",
category_id: null,
});
watch(
() => props.defaultCategoryId,
(defaultCategoryId) => {
form.value.category_id = defaultCategoryId || null;
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleCreate = () => {
const name = form.value.name.trim();
if (!name) {
return;
}
emit("create", {
name,
category_id: form.value.category_id || null,
});
form.value = {
name: "",
category_id: props.defaultCategoryId || null,
};
};
</script>

View File

@@ -1,69 +0,0 @@
<template>
<div class="note-list" :class="{ 'note-list--list': viewMode === 'list' }">
<div v-if="notes.length === 0" class="empty-notes-state" role="status" aria-live="polite">
<i class="mdi mdi-file-document-outline empty-notes-icon" aria-hidden="true"></i>
<h3 class="empty-notes-title">No Notes Yet</h3>
<p class="empty-notes-message">This space is empty for now. Create your first note to get started.</p>
</div>
<div v-for="note in notes" :key="note.id" class="note-card" :class="{ 'is-pinned': note.is_pinned, 'is-featured': note.is_favorite || note.is_featured }" @click="selectNote(note)">
<h5 class="note-title">
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ note.title }}
</h5>
<p class="note-preview">{{ getDescription(note) }}</p>
<small class="text-muted">Updated: {{ formatDate(note.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
defineProps({
notes: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "loadMore"]);
const selectNote = (note) => {
emit("selectNote", note);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
const getDescription = (note) => {
const description = (note?.description || "").trim();
if (!description) {
return "No description";
}
return description;
};
</script>
<style scoped src="../assets/styles/scoped/components/NoteList.css"></style>

View File

@@ -3,23 +3,23 @@
<header class="search-results-header"> <header class="search-results-header">
<h2>Search Results</h2> <h2>Search Results</h2>
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p> <p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p> <p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
</header> </header>
<div v-if="!query" class="empty-state"> <div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<h3>Start your search</h3> <h3>Start your search</h3>
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p> <p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
</div> </div>
<div v-else-if="totalResults === 0" class="empty-state"> <div v-else-if="totalResults === 0" class="empty-state">
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
<h3>No matching notes</h3> <h3>No matching results</h3>
<p>Try different keywords or a shorter phrase.</p> <p>Try different keywords or a shorter phrase.</p>
</div> </div>
<div v-else> <div v-else>
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" /> <WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages"> <nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button> <button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
@@ -32,14 +32,14 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import NoteList from "./NoteList.vue"; import WorkspaceList from "./WorkspaceList.vue";
const props = defineProps({ const props = defineProps({
query: { query: {
type: String, type: String,
default: "", default: "",
}, },
notes: { items: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
@@ -57,9 +57,9 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["select-note", "page-change"]); const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
const totalResults = computed(() => props.notes.length); const totalResults = computed(() => props.items.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize))); const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
const normalizedPage = computed(() => { const normalizedPage = computed(() => {
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
return Math.min(props.currentPage, totalPages.value); return Math.min(props.currentPage, totalPages.value);
}); });
const paginatedNotes = computed(() => { const paginatedItems = computed(() => {
const start = (normalizedPage.value - 1) * props.pageSize; const start = (normalizedPage.value - 1) * props.pageSize;
return props.notes.slice(start, start + props.pageSize); return props.items.slice(start, start + props.pageSize);
}); });
const goToPage = (page) => { const goToPage = (page) => {
@@ -83,6 +83,3 @@ const goToPage = (page) => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style> <style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>

View File

@@ -5,19 +5,9 @@
<h4 class="mb-0">Tasks</h4> <h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p> <p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div> </div>
<button class="btn btn-primary" @click="emit('create-task')">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</div> </div>
<div class="task-filters"> <div class="task-filters">
<select v-model="filterCategory" class="form-select" @change="emitFilters">
<option value="">All categories</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
<select v-model="filterStatus" class="form-select" @change="emitFilters"> <select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option> <option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id"> <option v-for="status in statuses" :key="status.id" :value="status.id">
@@ -245,15 +235,10 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: {
type: Array,
default: () => [],
},
}); });
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]); const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]);
const filterCategory = ref("");
const filterStatus = ref(""); const filterStatus = ref("");
const filterParent = ref(""); const filterParent = ref("");
const showStatusModal = ref(false); const showStatusModal = ref(false);
@@ -307,7 +292,6 @@ const statusSections = computed(() =>
const emitFilters = () => { const emitFilters = () => {
emit("filter-change", { emit("filter-change", {
categoryId: filterCategory.value || null,
statusId: filterStatus.value || null, statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null, parentTaskId: filterParent.value || null,
}); });
@@ -489,6 +473,3 @@ const deleteStatusFromModal = () => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style> <style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>

View File

@@ -16,12 +16,6 @@
<label class="form-label mt-3">Description</label> <label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea> <textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Category</label>
<select v-model="localTask.category_id" class="form-select">
<option value="">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
</select>
<label class="form-label mt-3">Parent Task</label> <label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select"> <select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option> <option value="">No parent (top level)</option>
@@ -83,10 +77,6 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: {
type: Array,
default: () => [],
},
parentTaskOptions: { parentTaskOptions: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -107,12 +97,12 @@ watch(
localTask.value = { localTask.value = {
title: "", title: "",
description: "", description: "",
category_id: "", task_list_id: "",
status_id: props.statuses[0]?.id || "", status_id: props.statuses[0]?.id || "",
parent_task_id: "", parent_task_id: "",
note_links: [], note_links: [],
...value, ...value,
category_id: value?.category_id || "", task_list_id: value?.task_list_id || "",
parent_task_id: value?.parent_task_id || "", parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [], note_links: value?.note_links || [],
}; };
@@ -138,13 +128,10 @@ const stepClass = (status) => {
const saveTask = () => { const saveTask = () => {
emit("save-task", { emit("save-task", {
...localTask.value, ...localTask.value,
category_id: localTask.value.category_id || null, task_list_id: localTask.value.task_list_id || null,
parent_task_id: localTask.value.parent_task_id || null, parent_task_id: localTask.value.parent_task_id || null,
}); });
}; };
</script> </script>
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style> <style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="workspace-list" :class="{ 'workspace-list--list': viewMode === 'list' }">
<div v-if="items.length === 0" class="empty-workspace-state" role="status" aria-live="polite">
<i class="mdi mdi-view-grid-outline empty-workspace-icon" aria-hidden="true"></i>
<h3 class="empty-workspace-title">Nothing Here Yet</h3>
<p class="empty-workspace-message">This view has no notes or task lists yet.</p>
</div>
<div v-for="item in items" :key="`${item.kind}-${item.id}`" class="content-card" :class="contentCardClass(item)" @click="openItem(item)">
<h5 class="content-title">
<template v-if="item.kind === 'note'">
<i v-if="item.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="item.is_favorite || item.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ item.title }}
</template>
<template v-else>
<i class="mdi mdi-format-list-checkbox list-icon" aria-hidden="true"></i>
{{ item.name }}
</template>
</h5>
<p class="content-preview">{{ getDescription(item) }}</p>
<small class="text-muted">Updated: {{ formatDate(item.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click.stop="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "selectTaskList", "loadMore"]);
const openItem = (item) => {
if (item.kind === "task-list") {
emit("selectTaskList", item);
return;
}
emit("selectNote", item);
};
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
const getDescription = (item) => {
const description = (item?.description || "").trim();
if (description) {
return description;
}
return item.kind === "task-list" ? "Open this task list to manage tasks." : "No description";
};
const contentCardClass = (item) => ({
"is-pinned": item.kind === "note" && item.is_pinned,
"is-featured": item.kind === "note" && (item.is_favorite || item.is_featured),
"is-task-list": item.kind === "task-list",
});
</script>
<style scoped src="../assets/styles/scoped/components/WorkspaceList.css"></style>

View File

@@ -13,12 +13,13 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false); const notesLoading = ref(false);
const categories = ref([]); const categories = ref([]);
const categoryTree = ref([]); const categoryTree = ref([]);
const taskLists = ref([]);
const tasks = ref([]); const tasks = ref([]);
const taskStatuses = ref([]); const taskStatuses = ref([]);
const noteLinkedTasks = ref([]); const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => { const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -227,6 +228,39 @@ export const useSpaceStore = defineStore("space", () => {
} }
}; };
const fetchTaskLists = async (spaceId) => {
if (!spaceId) {
taskLists.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
taskLists.value = response.data || [];
return taskLists.value;
} catch (error) {
console.error("Error fetching task lists:", error);
taskLists.value = [];
return [];
}
};
const createTaskList = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const updateTaskList = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const deleteTaskList = async (spaceId, taskListId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
await fetchTaskLists(spaceId);
};
const createTaskStatus = async (spaceId, payload) => { const createTaskStatus = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId);
@@ -258,8 +292,8 @@ export const useSpaceStore = defineStore("space", () => {
return []; return [];
} }
const params = {}; const params = {};
if (filters.categoryId) { if (filters.taskListId) {
params.categoryId = filters.categoryId; params.taskListId = filters.taskListId;
} }
if (filters.statusId) { if (filters.statusId) {
params.statusId = filters.statusId; params.statusId = filters.statusId;
@@ -344,6 +378,7 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading, notesLoading,
categories, categories,
categoryTree, categoryTree,
taskLists,
tasks, tasks,
taskStatuses, taskStatuses,
noteLinkedTasks, noteLinkedTasks,
@@ -363,6 +398,10 @@ export const useSpaceStore = defineStore("space", () => {
searchNotes, searchNotes,
clearSearchResults, clearSearchResults,
fetchTaskStatuses, fetchTaskStatuses,
fetchTaskLists,
createTaskList,
updateTaskList,
deleteTaskList,
createTaskStatus, createTaskStatus,
updateTaskStatus, updateTaskStatus,
deleteTaskStatus, deleteTaskStatus,