From 1c6506911ec13ad858269748dd72d94d30e34791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 1 Apr 2026 23:58:43 +0100 Subject: [PATCH] feat: add workflow sorting with drag-and-drop reordering (#520) --- .../executor/executor_mocks_test.go | 3 + apps/backend/internal/task/dto/dto.go | 2 + .../task/handlers/process_handlers_test.go | 3 + .../task/handlers/workflow_handlers.go | 46 +++++ apps/backend/internal/task/models/models.go | 1 + .../internal/task/repository/interface.go | 1 + .../internal/task/repository/sqlite/base.go | 2 + .../task/repository/sqlite/workflow.go | 53 +++++- .../repository/workflow_repository_test.go | 139 +++++++++++++++ .../task/service/service_resources.go | 10 ++ apps/backend/pkg/api/v1/workflow.go | 1 + apps/backend/pkg/websocket/actions.go | 11 +- apps/web/app/actions/workspaces.ts | 7 + .../workspace/workspace-workflows-client.tsx | 160 ++++++++++++++---- .../components/kanban/swimlane-container.tsx | 155 +++++++++++++---- .../web/components/kanban/swimlane-header.tsx | 53 +++--- .../components/kanban/swimlane-section.tsx | 5 +- .../task-create-dialog-form-body.tsx | 15 +- apps/web/components/workflow-selector-row.tsx | 14 +- apps/web/e2e/helpers/api-client.ts | 9 + apps/web/e2e/pages/workflow-settings-page.ts | 17 ++ .../tests/settings/config-management.spec.ts | 8 +- .../tests/workflow/workflow-sorting.spec.ts | 104 ++++++++++++ apps/web/hooks/use-workflows.ts | 1 + apps/web/lib/api/domains/kanban-api.ts | 15 ++ .../lib/state/slices/kanban/kanban-slice.ts | 12 ++ apps/web/lib/state/slices/kanban/types.ts | 9 +- apps/web/lib/state/store.ts | 1 + apps/web/lib/types/http.ts | 1 + 29 files changed, 749 insertions(+), 109 deletions(-) create mode 100644 apps/web/e2e/tests/workflow/workflow-sorting.spec.ts diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index b1b58cd386..d5d7f0a938 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -357,6 +357,9 @@ func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { return nil, nil } +func (m *mockRepository) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { + return nil +} // Message operations func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 70244655c0..6255f611ff 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -13,6 +13,7 @@ type WorkflowDTO struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -333,6 +334,7 @@ func FromWorkflow(workflow *models.Workflow) WorkflowDTO { WorkspaceID: workflow.WorkspaceID, Name: workflow.Name, Description: description, + SortOrder: workflow.SortOrder, CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt, } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 29ad1792a3..fd7701d054 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -121,6 +121,9 @@ func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { return nil, nil } +func (m *mockRepository) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { + return nil +} func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { return nil } diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index b722fa7cac..267f7a268f 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -58,6 +58,7 @@ func (h *WorkflowHandlers) registerHTTP(router *gin.Engine) { api.POST("/workflows", h.httpCreateWorkflow) api.PATCH("/workflows/:id", h.httpUpdateWorkflow) api.DELETE("/workflows/:id", h.httpDeleteWorkflow) + api.PUT("/workspaces/:id/workflows/reorder", h.httpReorderWorkflows) } func (h *WorkflowHandlers) registerWS(dispatcher *ws.Dispatcher) { @@ -66,6 +67,7 @@ func (h *WorkflowHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionWorkflowGet, h.wsGetWorkflow) dispatcher.RegisterFunc(ws.ActionWorkflowUpdate, h.wsUpdateWorkflow) dispatcher.RegisterFunc(ws.ActionWorkflowDelete, h.wsDeleteWorkflow) + dispatcher.RegisterFunc(ws.ActionWorkflowReorder, h.wsReorderWorkflows) } func (h *WorkflowHandlers) listWorkflows(ctx context.Context, workspaceID string) (dto.ListWorkflowsResponse, error) { @@ -401,6 +403,50 @@ func (h *WorkflowHandlers) wsDeleteWorkflow(ctx context.Context, msg *ws.Message }) } +type httpReorderWorkflowsRequest struct { + WorkflowIDs []string `json:"workflow_ids"` +} + +func (h *WorkflowHandlers) httpReorderWorkflows(c *gin.Context) { + workspaceID := c.Param("id") + var req httpReorderWorkflowsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if len(req.WorkflowIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_ids is required"}) + return + } + if err := h.service.ReorderWorkflows(c.Request.Context(), workspaceID, req.WorkflowIDs); err != nil { + h.logger.Error("failed to reorder workflows", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder workflows"}) + return + } + c.JSON(http.StatusOK, dto.SuccessResponse{Success: true}) +} + +func (h *WorkflowHandlers) wsReorderWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + WorkflowIDs []string `json:"workflow_ids"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) + } + if len(req.WorkflowIDs) == 0 { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_ids is required", nil) + } + if err := h.service.ReorderWorkflows(ctx, req.WorkspaceID, req.WorkflowIDs); err != nil { + h.logger.Error("failed to reorder workflows", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to reorder workflows", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) +} + // getStepsForWorkflow returns workflow steps for a workflow as DTOs. func (h *WorkflowHandlers) getStepsForWorkflow( ctx context.Context, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 3bda12cdae..b180d39d20 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -53,6 +53,7 @@ type Workflow struct { Name string `json:"name"` Description string `json:"description"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index df3ab72844..480ec4465a 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -56,6 +56,7 @@ type WorkflowRepository interface { UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error DeleteWorkflow(ctx context.Context, id string) error ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) + ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error } // MessageRepository handles message persistence and lookups. diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index ca107ac12f..4f7aa788f5 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -156,6 +156,8 @@ func (r *Repository) runMigrations() error { if err := r.migrateSessionsRemoveWorkflowStepID(); err != nil { return err } + // Add sort_order column to workflows for user-defined ordering (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) return nil } diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 58edb6ad9c..8cff6a3bf2 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -39,10 +39,21 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl workflow.CreatedAt = now workflow.UpdatedAt = now - _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt) + // Auto-assign sort_order as max+1 within the workspace. + // Use writer connection (r.db) to avoid stale reads under concurrent creation. + var maxOrder int + err := r.db.QueryRowContext(ctx, r.db.Rebind( + `SELECT COALESCE(MAX(sort_order), -1) FROM workflows WHERE workspace_id = ?`, + ), workflow.WorkspaceID).Scan(&maxOrder) + if err != nil { + return fmt.Errorf("failed to get max sort_order: %w", err) + } + workflow.SortOrder = maxOrder + 1 + + _, err = r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.SortOrder, workflow.CreatedAt, workflow.UpdatedAt) return err } @@ -53,9 +64,9 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl var workflowTemplateID sql.NullString err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at + SELECT id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at FROM workflows WHERE id = ? - `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) + `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow not found: %s", id) @@ -129,14 +140,14 @@ func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { // ListWorkflows returns all workflows func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { query := ` - SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM workflows + SELECT id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at FROM workflows ` var args []interface{} if workspaceID != "" { query += " WHERE workspace_id = ?" args = append(args, workspaceID) } - query += " ORDER BY created_at DESC" + query += " ORDER BY sort_order ASC, created_at ASC" rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { @@ -148,7 +159,7 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]* for rows.Next() { workflow := &models.Workflow{} var workflowTemplateID sql.NullString - err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) + err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) if err != nil { return nil, err } @@ -159,3 +170,27 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]* } return result, rows.Err() } + +// ReorderWorkflows updates sort_order for workflows within a workspace using a transaction. +func (r *Repository) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().UTC() + for i, id := range workflowIDs { + result, err := tx.ExecContext(ctx, r.db.Rebind( + `UPDATE workflows SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?`, + ), i, now, id, workspaceID) + if err != nil { + return fmt.Errorf("failed to update sort_order for workflow %s: %w", id, err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("workflow not found in workspace: %s", id) + } + } + return tx.Commit() +} diff --git a/apps/backend/internal/task/repository/workflow_repository_test.go b/apps/backend/internal/task/repository/workflow_repository_test.go index d608aa070f..1adbbab3ac 100644 --- a/apps/backend/internal/task/repository/workflow_repository_test.go +++ b/apps/backend/internal/task/repository/workflow_repository_test.go @@ -101,3 +101,142 @@ func TestSQLiteRepository_ListWorkflows(t *testing.T) { t.Errorf("expected 2 workflows, got %d", len(workflows)) } } + +func TestSQLiteRepository_WorkflowSortOrder(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + + // Create workflows and verify auto-assigned sort_order + wf1 := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "First"} + wf2 := &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Second"} + wf3 := &models.Workflow{ID: "wf-3", WorkspaceID: "ws-1", Name: "Third"} + _ = repo.CreateWorkflow(ctx, wf1) + _ = repo.CreateWorkflow(ctx, wf2) + _ = repo.CreateWorkflow(ctx, wf3) + + if wf1.SortOrder != 0 { + t.Errorf("expected first workflow sort_order=0, got %d", wf1.SortOrder) + } + if wf2.SortOrder != 1 { + t.Errorf("expected second workflow sort_order=1, got %d", wf2.SortOrder) + } + if wf3.SortOrder != 2 { + t.Errorf("expected third workflow sort_order=2, got %d", wf3.SortOrder) + } + + // Verify sort_order is persisted via Get + retrieved, _ := repo.GetWorkflow(ctx, "wf-2") + if retrieved.SortOrder != 1 { + t.Errorf("expected retrieved sort_order=1, got %d", retrieved.SortOrder) + } + + // Verify ListWorkflows returns in sort_order + workflows, _ := repo.ListWorkflows(ctx, "ws-1") + if len(workflows) != 3 { + t.Fatalf("expected 3 workflows, got %d", len(workflows)) + } + if workflows[0].Name != "First" || workflows[1].Name != "Second" || workflows[2].Name != "Third" { + t.Errorf("expected workflows in sort_order, got: %s, %s, %s", + workflows[0].Name, workflows[1].Name, workflows[2].Name) + } +} + +func TestSQLiteRepository_ReorderWorkflows(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "First"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Second"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-3", WorkspaceID: "ws-1", Name: "Third"}) + + // Reorder: Third, First, Second + err := repo.ReorderWorkflows(ctx, "ws-1", []string{"wf-3", "wf-1", "wf-2"}) + if err != nil { + t.Fatalf("failed to reorder workflows: %v", err) + } + + workflows, _ := repo.ListWorkflows(ctx, "ws-1") + if len(workflows) != 3 { + t.Fatalf("expected 3 workflows, got %d", len(workflows)) + } + if workflows[0].Name != "Third" || workflows[1].Name != "First" || workflows[2].Name != "Second" { + t.Errorf("expected reordered workflows [Third, First, Second], got: [%s, %s, %s]", + workflows[0].Name, workflows[1].Name, workflows[2].Name) + } + + // Verify individual sort_order values + wf3, _ := repo.GetWorkflow(ctx, "wf-3") + wf1, _ := repo.GetWorkflow(ctx, "wf-1") + wf2, _ := repo.GetWorkflow(ctx, "wf-2") + if wf3.SortOrder != 0 { + t.Errorf("expected wf-3 sort_order=0, got %d", wf3.SortOrder) + } + if wf1.SortOrder != 1 { + t.Errorf("expected wf-1 sort_order=1, got %d", wf1.SortOrder) + } + if wf2.SortOrder != 2 { + t.Errorf("expected wf-2 sort_order=2, got %d", wf2.SortOrder) + } +} + +func TestSQLiteRepository_ReorderWorkflows_InvalidID(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "First"}) + + err := repo.ReorderWorkflows(ctx, "ws-1", []string{"wf-1", "nonexistent"}) + if err == nil { + t.Error("expected error for nonexistent workflow ID in reorder") + } +} + +func TestSQLiteRepository_ReorderWorkflows_WrongWorkspace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "First"}) + + // Try to reorder workflow from ws-1 in ws-2 — should fail + err := repo.ReorderWorkflows(ctx, "ws-2", []string{"wf-1"}) + if err == nil { + t.Error("expected error when reordering workflow from wrong workspace") + } +} + +func TestSQLiteRepository_WorkflowSortOrder_Isolation(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) + + // Create workflows in different workspaces — sort_order should be independent + wf1 := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WS1 First"} + wf2 := &models.Workflow{ID: "wf-2", WorkspaceID: "ws-2", Name: "WS2 First"} + wf3 := &models.Workflow{ID: "wf-3", WorkspaceID: "ws-1", Name: "WS1 Second"} + _ = repo.CreateWorkflow(ctx, wf1) + _ = repo.CreateWorkflow(ctx, wf2) + _ = repo.CreateWorkflow(ctx, wf3) + + if wf1.SortOrder != 0 { + t.Errorf("expected ws-1 first workflow sort_order=0, got %d", wf1.SortOrder) + } + if wf2.SortOrder != 0 { + t.Errorf("expected ws-2 first workflow sort_order=0, got %d", wf2.SortOrder) + } + if wf3.SortOrder != 1 { + t.Errorf("expected ws-1 second workflow sort_order=1, got %d", wf3.SortOrder) + } +} diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 1a1a13f5c3..09deb8e20a 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -198,6 +198,16 @@ func (s *Service) ListWorkflows(ctx context.Context, workspaceID string) ([]*mod return s.workflows.ListWorkflows(ctx, workspaceID) } +// ReorderWorkflows updates sort_order for workflows within a workspace. +func (s *Service) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { + if err := s.workflows.ReorderWorkflows(ctx, workspaceID, workflowIDs); err != nil { + s.logger.Error("failed to reorder workflows", zap.String("workspace_id", workspaceID), zap.Error(err)) + return err + } + s.logger.Info("reordered workflows", zap.String("workspace_id", workspaceID), zap.Int("count", len(workflowIDs))) + return nil +} + // Repository operations func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) { diff --git a/apps/backend/pkg/api/v1/workflow.go b/apps/backend/pkg/api/v1/workflow.go index 4159a94d9f..7466322642 100644 --- a/apps/backend/pkg/api/v1/workflow.go +++ b/apps/backend/pkg/api/v1/workflow.go @@ -8,6 +8,7 @@ type Workflow struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index f73993b6b3..ddc5d81f0d 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -6,11 +6,12 @@ const ( ActionHealthCheck = "health.check" // Workflow actions - ActionWorkflowList = "workflow.list" - ActionWorkflowCreate = "workflow.create" - ActionWorkflowGet = "workflow.get" - ActionWorkflowUpdate = "workflow.update" - ActionWorkflowDelete = "workflow.delete" + ActionWorkflowList = "workflow.list" + ActionWorkflowCreate = "workflow.create" + ActionWorkflowGet = "workflow.get" + ActionWorkflowUpdate = "workflow.update" + ActionWorkflowDelete = "workflow.delete" + ActionWorkflowReorder = "workflow.reorder" // Workspace actions ActionWorkspaceList = "workspace.list" diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 5f44a677ae..a5453a9f64 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -135,6 +135,13 @@ export async function deleteWorkflowAction(id: string) { await fetchJson(`${apiBaseUrl}/api/v1/workflows/${id}`, { method: "DELETE" }); } +export async function reorderWorkflowsAction(workspaceId: string, workflowIds: string[]) { + return fetchJson<{ success: boolean }>( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/reorder`, + { method: "PUT", body: JSON.stringify({ workflow_ids: workflowIds }) }, + ); +} + export async function listRepositoriesAction( workspaceId: string, params?: { includeScripts?: boolean }, diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 76b4fcdf9d..96771f24ef 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -3,7 +3,28 @@ import { useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { IconDownload, IconLayoutColumns, IconPlus, IconUpload } from "@tabler/icons-react"; +import { + IconDownload, + IconGripVertical, + IconLayoutColumns, + IconPlus, + IconUpload, +} from "@tabler/icons-react"; +import { + DndContext, + closestCenter, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { SettingsSection } from "@/components/settings/settings-section"; @@ -17,6 +38,7 @@ import { updateWorkflowAction, exportAllWorkflowsAction, importWorkflowsAction, + reorderWorkflowsAction, } from "@/app/actions/workspaces"; import type { Workflow, @@ -273,57 +295,116 @@ function WorkflowSectionActions({ onExport, onImport, onAdd }: WorkflowSectionAc type WorkflowListProps = { workflowItems: Workflow[]; + workspaceId: string; templateStepsById: Map; isWorkflowDirty: (wf: Workflow) => boolean; onUpdate: (id: string, u: { name?: string; description?: string }) => void; onDelete: (id: string) => void; onSave: (id: string) => void; onCreated: (id: string, wf: Workflow) => void; + onReorder: (items: Workflow[]) => void; }; +function SortableWorkflowItem({ + workflow, + children, +}: { + workflow: Workflow; + children: React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: workflow.id, + }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( +
+
+ +
+ {children} +
+ ); +} + function WorkflowList({ workflowItems, + workspaceId, templateStepsById, isWorkflowDirty, onUpdate, onDelete, onSave, onCreated, + onReorder, }: WorkflowListProps) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = workflowItems.findIndex((wf) => wf.id === active.id); + const newIndex = workflowItems.findIndex((wf) => wf.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = arrayMove(workflowItems, oldIndex, newIndex); + onReorder(reordered); + // Persist to backend (skip temp workflows) + const persistedIds = reordered.filter((wf) => !wf.id.startsWith("temp-")).map((wf) => wf.id); + if (persistedIds.length > 0) { + reorderWorkflowsAction(workspaceId, persistedIds).catch(() => {}); + } + }; + return ( -
- {workflowItems.map((workflow) => { - const isTempWorkflow = workflow.id.startsWith("temp-"); - const templateSteps = - isTempWorkflow && workflow.workflow_template_id - ? (templateStepsById.get(workflow.workflow_template_id) ?? []) - : DEFAULT_CUSTOM_STEPS; - const initialWorkflowSteps = - isTempWorkflow && templateSteps.length - ? buildWorkflowSteps(workflow, templateSteps) - : undefined; - return ( - w.id !== workflow.id && !w.id.startsWith("temp-"), - )} - onUpdateWorkflow={(updates) => onUpdate(workflow.id, updates)} - onDeleteWorkflow={async () => { - await onDelete(workflow.id); - }} - onSaveWorkflow={async () => { - await onSave(workflow.id); - }} - onWorkflowCreated={(created) => onCreated(workflow.id, created)} - /> - ); - })} -
+ + wf.id)} + strategy={verticalListSortingStrategy} + > +
+ {workflowItems.map((workflow) => { + const isTempWorkflow = workflow.id.startsWith("temp-"); + const templateSteps = + isTempWorkflow && workflow.workflow_template_id + ? (templateStepsById.get(workflow.workflow_template_id) ?? []) + : DEFAULT_CUSTOM_STEPS; + const initialWorkflowSteps = + isTempWorkflow && templateSteps.length + ? buildWorkflowSteps(workflow, templateSteps) + : undefined; + return ( + + w.id !== workflow.id && !w.id.startsWith("temp-"), + )} + onUpdateWorkflow={(updates) => onUpdate(workflow.id, updates)} + onDeleteWorkflow={async () => { + await onDelete(workflow.id); + }} + onSaveWorkflow={async () => { + await onSave(workflow.id); + }} + onWorkflowCreated={(created) => onCreated(workflow.id, created)} + /> + + ); + })} +
+
+
); } @@ -396,12 +477,14 @@ export function WorkspaceWorkflowsClient({ > @@ -457,6 +540,14 @@ function useWorkspaceWorkflowsPage( handleSaveWorkflow, } = actions; + const handleReorderWorkflows = (reordered: Workflow[]) => { + setWorkflowItems(reordered); + setSavedWorkflowItems((prev) => { + const orderMap = new Map(reordered.map((wf, i) => [wf.id, i])); + return [...prev].sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); + }); + }; + const templateStepsById = useMemo( () => new Map(workflowTemplates.map((t) => [t.id, t.default_steps ?? []])), [workflowTemplates], @@ -489,6 +580,7 @@ function useWorkspaceWorkflowsPage( handleDeleteWorkflow, handleWorkflowCreated, handleSaveWorkflow, + handleReorderWorkflows, workflowTemplates, templateStepsById, }; diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index b941a61791..75fa46695a 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -1,10 +1,26 @@ "use client"; -import { type ComponentType, useMemo } from "react"; +import { type ComponentType, type HTMLAttributes, useCallback, useMemo } from "react"; +import { + DndContext, + closestCenter, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useAppStore } from "@/components/state-provider"; import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; +import { reorderWorkflows } from "@/lib/api"; import { SwimlaneSection } from "./swimlane-section"; import { getViewByStoredValue, @@ -85,6 +101,7 @@ type WorkflowItemProps = { tasks: Task[]; ViewComponent: ComponentType; hideHeader: boolean; + isSortable: boolean; isCollapsed: boolean; onToggleCollapse: () => void; onPreviewTask: (task: Task) => void; @@ -98,7 +115,30 @@ type WorkflowItemProps = { showMaximizeButton?: boolean; }; -function WorkflowItem({ +function SortableWorkflowItem({ wf, hideHeader, isSortable, ...rest }: WorkflowItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: wf.id, + disabled: !isSortable, + }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + const dragHandleProps = isSortable && !hideHeader ? { ...attributes, ...listeners } : undefined; + return ( +
+ +
+ ); +} + +function WorkflowItemContent({ wf, snapshot, tasks, @@ -106,8 +146,9 @@ function WorkflowItem({ hideHeader, isCollapsed, onToggleCollapse, + dragHandleProps, ...viewProps -}: WorkflowItemProps) { +}: Omit & { dragHandleProps?: HTMLAttributes }) { const steps = [...snapshot.steps].sort((a, b) => a.position - b.position); const content = ; @@ -121,12 +162,45 @@ function WorkflowItem({ taskCount={tasks.length} isCollapsed={isCollapsed} onToggleCollapse={onToggleCollapse} + dragHandleProps={dragHandleProps} > {content} ); } +function useWorkflowReorder( + orderedWorkflows: { id: string; name: string }[], + workflowFilter: string | null, +) { + const reorderWorkflowItems = useAppStore((state) => state.reorderWorkflowItems); + const workflows = useAppStore((state) => state.workflows.items); + const workspaceId = workflows[0]?.workspaceId; + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + const canSort = !workflowFilter && orderedWorkflows.length > 1; + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = orderedWorkflows.findIndex((wf) => wf.id === active.id); + const newIndex = orderedWorkflows.findIndex((wf) => wf.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = arrayMove(orderedWorkflows, oldIndex, newIndex); + reorderWorkflowItems(reordered.map((wf) => wf.id)); + if (workspaceId) { + reorderWorkflows( + workspaceId, + reordered.map((wf) => wf.id), + ).catch(() => {}); + } + }, + [orderedWorkflows, reorderWorkflowItems, workspaceId], + ); + + return { sensors, canSort, handleDragEnd }; +} + export function SwimlaneContainer({ viewMode, workflowFilter, @@ -168,6 +242,11 @@ export function SwimlaneContainer({ }, [workflowFilter, workflows, snapshots]); const getFilteredTasks = (wfId: string) => filterTasks(snapshots, wfId, repoFilter, searchQuery); + const { + sensors: workflowSensors, + canSort: canSortWorkflows, + handleDragEnd: handleWorkflowDragEnd, + } = useWorkflowReorder(orderedWorkflows, workflowFilter); const emptyMessage = getEmptyMessage( isLoading, @@ -184,37 +263,47 @@ export function SwimlaneContainer({ const ViewComponent = (getViewByStoredValue(viewMode) ?? getDefaultView()).component; const hideHeaders = isMobile && (workflowFilter !== null || orderedWorkflows.length === 1); - const containerClass = isMobile - ? "flex-1 min-h-0 overflow-y-auto pb-4 space-y-3" - : "flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-3"; + const cls = `flex-1 min-h-0 overflow-y-auto${isMobile ? "" : " px-4"} pb-4 space-y-3`; return ( -
- {visibleWorkflows.map((wf) => { - const snapshot = snapshots[wf.id]; - if (!snapshot) return null; - return ( - toggleCollapse(wf.id)} - onPreviewTask={onPreviewTask} - onOpenTask={onOpenTask} - onEditTask={onEditTask} - onDeleteTask={onDeleteTask} - onArchiveTask={onArchiveTask} - onMoveError={onMoveError} - deletingTaskId={deletingTaskId} - archivingTaskId={archivingTaskId} - showMaximizeButton={showMaximizeButton} - /> - ); - })} -
+ + wf.id)} + strategy={verticalListSortingStrategy} + > +
+ {visibleWorkflows.map((wf) => { + const snapshot = snapshots[wf.id]; + if (!snapshot) return null; + return ( + toggleCollapse(wf.id)} + onPreviewTask={onPreviewTask} + onOpenTask={onOpenTask} + onEditTask={onEditTask} + onDeleteTask={onDeleteTask} + onArchiveTask={onArchiveTask} + onMoveError={onMoveError} + deletingTaskId={deletingTaskId} + archivingTaskId={archivingTaskId} + showMaximizeButton={showMaximizeButton} + /> + ); + })} +
+
+
); } diff --git a/apps/web/components/kanban/swimlane-header.tsx b/apps/web/components/kanban/swimlane-header.tsx index 5e285e8902..162764c386 100644 --- a/apps/web/components/kanban/swimlane-header.tsx +++ b/apps/web/components/kanban/swimlane-header.tsx @@ -1,7 +1,8 @@ "use client"; +import type { HTMLAttributes } from "react"; import { Badge } from "@kandev/ui/badge"; -import { IconChevronRight } from "@tabler/icons-react"; +import { IconChevronRight, IconGripVertical } from "@tabler/icons-react"; import { cn } from "@kandev/ui/lib/utils"; export type SwimlaneHeaderProps = { @@ -9,6 +10,7 @@ export type SwimlaneHeaderProps = { taskCount: number; isCollapsed: boolean; onToggleCollapse: () => void; + dragHandleProps?: HTMLAttributes; }; export function SwimlaneHeader({ @@ -16,26 +18,37 @@ export function SwimlaneHeader({ taskCount, isCollapsed, onToggleCollapse, + dragHandleProps, }: SwimlaneHeaderProps) { return ( - +
+ {dragHandleProps && ( +
+ +
+ )} + +
); } diff --git a/apps/web/components/kanban/swimlane-section.tsx b/apps/web/components/kanban/swimlane-section.tsx index 3f21c26890..cd934db487 100644 --- a/apps/web/components/kanban/swimlane-section.tsx +++ b/apps/web/components/kanban/swimlane-section.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; import { SwimlaneHeader } from "./swimlane-header"; export type SwimlaneSectionProps = { @@ -9,6 +9,7 @@ export type SwimlaneSectionProps = { taskCount: number; isCollapsed: boolean; onToggleCollapse: () => void; + dragHandleProps?: HTMLAttributes; children: ReactNode; }; @@ -17,6 +18,7 @@ export function SwimlaneSection({ taskCount, isCollapsed, onToggleCollapse, + dragHandleProps, children, }: SwimlaneSectionProps) { return ( @@ -26,6 +28,7 @@ export function SwimlaneSection({ taskCount={taskCount} isCollapsed={isCollapsed} onToggleCollapse={onToggleCollapse} + dragHandleProps={dragHandleProps} /> {!isCollapsed && children} diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index a37cd51848..7352b59199 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo } from "react"; +import { memo, useCallback, useState } from "react"; import Link from "next/link"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; @@ -237,13 +237,24 @@ export const WorkflowSection = memo(function WorkflowSection({ effectiveWorkflowId, onWorkflowChange, }: WorkflowSectionProps) { + const [lastUsedWorkflowId, setLastUsedWorkflowId] = useState(null); + + const handleWorkflowChange = useCallback( + (workflowId: string) => { + setLastUsedWorkflowId(workflowId); + onWorkflowChange(workflowId); + }, + [onWorkflowChange], + ); + if (!isCreateMode || workflows.length <= 1 || isTaskStarted) return null; return ( ); }); diff --git a/apps/web/components/workflow-selector-row.tsx b/apps/web/components/workflow-selector-row.tsx index a9fa87a279..d6250ab00f 100644 --- a/apps/web/components/workflow-selector-row.tsx +++ b/apps/web/components/workflow-selector-row.tsx @@ -33,6 +33,7 @@ type WorkflowSelectorRowProps = { snapshots: Record; selectedWorkflowId: string | null; onWorkflowChange: (workflowId: string) => void; + lastUsedWorkflowId?: string | null; }; export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ @@ -40,6 +41,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ snapshots, selectedWorkflowId, onWorkflowChange, + lastUsedWorkflowId, }: WorkflowSelectorRowProps) { const [open, setOpen] = useState(false); @@ -48,6 +50,16 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ [workflows, selectedWorkflowId], ); + // Sort workflows: last-used first, then original order (which is already by sort_order) + const sortedWorkflows = useMemo(() => { + if (!lastUsedWorkflowId) return workflows; + return [...workflows].sort((a, b) => { + if (a.id === lastUsedWorkflowId) return -1; + if (b.id === lastUsedWorkflowId) return 1; + return 0; + }); + }, [workflows, lastUsedWorkflowId]); + return ( @@ -59,7 +71,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
Workflow
- {workflows.map((wf) => { + {sortedWorkflows.map((wf) => { const isSelected = wf.id === selectedWorkflowId; const snapshot = snapshots[wf.id]; const steps = snapshot ? [...snapshot.steps].sort((a, b) => a.position - b.position) : []; diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index ca835662e8..ca2bc4b96c 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -170,6 +170,15 @@ export class ApiClient { }); } + async reorderWorkflows( + workspaceId: string, + workflowIds: string[], + ): Promise<{ success: boolean }> { + return this.request("PUT", `/api/v1/workspaces/${workspaceId}/workflows/reorder`, { + workflow_ids: workflowIds, + }); + } + async createTask( workspaceId: string, title: string, diff --git a/apps/web/e2e/pages/workflow-settings-page.ts b/apps/web/e2e/pages/workflow-settings-page.ts index 3a6464e821..ca7a7c57b2 100644 --- a/apps/web/e2e/pages/workflow-settings-page.ts +++ b/apps/web/e2e/pages/workflow-settings-page.ts @@ -78,6 +78,23 @@ export class WorkflowSettingsPage { return this.page.getByTestId("step-delete-confirm-dialog"); } + /** Returns the ordered names of all workflow cards on the page. */ + async getWorkflowOrder(): Promise { + const cards = this.page.locator('[data-testid^="workflow-card-"]'); + const count = await cards.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + const input = cards.nth(i).locator("input").first(); + names.push(await input.inputValue()); + } + return names; + } + + /** The drag handle for a specific workflow card. */ + dragHandle(workflowId: string): Locator { + return this.page.getByTestId(`workflow-drag-handle-${workflowId}`); + } + /** Open the "Add Workflow" dialog and create a workflow. */ async createWorkflow(name: string, templateName?: string) { await this.addWorkflowButton.click(); diff --git a/apps/web/e2e/tests/settings/config-management.spec.ts b/apps/web/e2e/tests/settings/config-management.spec.ts index a903e1d0f6..57cee21412 100644 --- a/apps/web/e2e/tests/settings/config-management.spec.ts +++ b/apps/web/e2e/tests/settings/config-management.spec.ts @@ -136,11 +136,13 @@ test.describe("Config-mode MCP — workflow management", () => { await runAndWait(testPage, session.task_id, "Workflow created"); - // Verify workflow was created via API + // Verify workflow was created via API. + // Filter by both name and description since the seed workflow shares the same name. const { workflows } = await apiClient.listWorkflows(seedData.workspaceId); - const created = workflows.find((w) => w.name === "E2E Workflow"); + const created = workflows.find( + (w) => w.name === "E2E Workflow" && w.description === "Created by E2E test", + ); expect(created).toBeTruthy(); - expect(created!.description).toBe("Created by E2E test"); }); test("agent can update a workflow", async ({ testPage, apiClient, seedData }) => { diff --git a/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts b/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts new file mode 100644 index 0000000000..c55a5d4fc4 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "../../fixtures/test-base"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; + +test.describe("Workflow sorting", () => { + test("API reorder persists workflow order", async ({ apiClient, seedData }) => { + const { workspaceId } = seedData; + + // Create additional workflows + const wfB = await apiClient.createWorkflow(workspaceId, "Workflow B", "simple"); + const wfC = await apiClient.createWorkflow(workspaceId, "Workflow C", "simple"); + + // Verify initial order: sorted by sort_order ASC (0, 1, 2) + const before = await apiClient.listWorkflows(workspaceId); + const namesBefore = before.workflows.map((w) => w.name); + expect(namesBefore).toEqual(["E2E Workflow", "Workflow B", "Workflow C"]); + + // Reorder: C, E2E, B + await apiClient.reorderWorkflows(workspaceId, [wfC.id, seedData.workflowId, wfB.id]); + + // Verify new order persists + const after = await apiClient.listWorkflows(workspaceId); + const namesAfter = after.workflows.map((w) => w.name); + expect(namesAfter).toEqual(["Workflow C", "E2E Workflow", "Workflow B"]); + }); + + test("settings page displays workflows in sort order", async ({ + testPage, + apiClient, + seedData, + }) => { + const { workspaceId } = seedData; + + // Create a second workflow + const wfExtra = await apiClient.createWorkflow(workspaceId, "Extra Workflow", "simple"); + + // Reorder: Extra first, then E2E + await apiClient.reorderWorkflows(workspaceId, [wfExtra.id, seedData.workflowId]); + + // Navigate to settings page + const page = new WorkflowSettingsPage(testPage); + await page.goto(workspaceId); + + // Verify order matches API reorder + const order = await page.getWorkflowOrder(); + expect(order).toEqual(["Extra Workflow", "E2E Workflow"]); + }); + + test("settings page shows drag handles for workflows", async ({ + testPage, + apiClient, + seedData, + }) => { + const { workspaceId } = seedData; + + // Create a second workflow so drag handles appear + await apiClient.createWorkflow(workspaceId, "Second Workflow", "simple"); + + const page = new WorkflowSettingsPage(testPage); + await page.goto(workspaceId); + + // Verify drag handles are visible + const handles = testPage.locator('[data-testid^="workflow-drag-handle-"]'); + await expect(handles.first()).toBeVisible(); + expect(await handles.count()).toBeGreaterThanOrEqual(2); + }); + + test("kanban board respects workflow sort order", async ({ testPage, apiClient, seedData }) => { + const { workspaceId, workflowId } = seedData; + + // Create a second workflow with a task so both show on kanban + const wfSecond = await apiClient.createWorkflow(workspaceId, "Second Board", "simple"); + const secondSteps = await apiClient.listWorkflowSteps(wfSecond.id); + const secondStartStep = secondSteps.steps.find((s) => s.is_start_step) ?? secondSteps.steps[0]; + + // Create tasks in both workflows so they appear + await apiClient.createTask(workspaceId, "Task in E2E", { + workflow_id: workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(workspaceId, "Task in Second", { + workflow_id: wfSecond.id, + workflow_step_id: secondStartStep.id, + }); + + // Reorder: Second Board first + await apiClient.reorderWorkflows(workspaceId, [wfSecond.id, workflowId]); + + // Clear workflow filter so kanban shows all workflows with swimlane headers + await apiClient.saveUserSettings({ + workspace_id: workspaceId, + workflow_filter_id: "", + }); + + // Navigate to kanban + await testPage.goto("/"); + await expect(testPage.getByTestId("swimlane-container")).toBeVisible({ timeout: 10000 }); + + // Verify swimlane headers appear in the reordered sequence + const headers = testPage.getByTestId("swimlane-header"); + await expect(headers.first()).toBeVisible(); + const firstHeader = await headers.first().textContent(); + expect(firstHeader).toContain("Second Board"); + }); +}); diff --git a/apps/web/hooks/use-workflows.ts b/apps/web/hooks/use-workflows.ts index 866d619025..b8e6954fa5 100644 --- a/apps/web/hooks/use-workflows.ts +++ b/apps/web/hooks/use-workflows.ts @@ -15,6 +15,7 @@ export function useWorkflows(workspaceId: string | null, enabled = true) { workspaceId: workflow.workspace_id, name: workflow.name, description: workflow.description, + sortOrder: workflow.sort_order ?? 0, })); setWorkflows(mapped); }) diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index e12a2d7ecb..9dac349d00 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -21,6 +21,21 @@ export async function fetchWorkflowSnapshot(workflowId: string, options?: ApiReq return fetchJson(`/api/v1/workflows/${workflowId}/snapshot`, options); } +export async function reorderWorkflows( + workspaceId: string, + workflowIds: string[], + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/workspaces/${workspaceId}/workflows/reorder`, { + ...options, + init: { + method: "PUT", + body: JSON.stringify({ workflow_ids: workflowIds }), + ...(options?.init ?? {}), + }, + }); +} + // Task operations export async function createTask( payload: { diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts index 6fb47a0272..3410b99d5b 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -30,6 +30,18 @@ export const createKanbanSlice: StateCreator< draft.workflows.activeId = workflows[0].id; } }), + reorderWorkflowItems: (workflowIds) => + set((draft) => { + const byId = new Map(draft.workflows.items.map((w) => [w.id, w])); + const reordered = workflowIds + .map((id) => byId.get(id)) + .filter((w): w is NonNullable => w != null); + // Append any items not in the reorder list (shouldn't happen, but defensive) + for (const item of draft.workflows.items) { + if (!workflowIds.includes(item.id)) reordered.push(item); + } + draft.workflows.items = reordered; + }), setActiveTask: (taskId) => set((draft) => { draft.tasks.activeTaskId = taskId; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index b77e83d123..13601600a7 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -56,7 +56,13 @@ export type KanbanMultiState = { }; export type WorkflowsState = { - items: Array<{ id: string; workspaceId: string; name: string; description?: string | null }>; + items: Array<{ + id: string; + workspaceId: string; + name: string; + description?: string | null; + sortOrder?: number; + }>; activeId: string | null; }; @@ -75,6 +81,7 @@ export type KanbanSliceState = { export type KanbanSliceActions = { setActiveWorkflow: (workflowId: string | null) => void; setWorkflows: (workflows: WorkflowsState["items"]) => void; + reorderWorkflowItems: (workflowIds: string[]) => void; setActiveTask: (taskId: string) => void; setActiveSession: (taskId: string, sessionId: string) => void; clearActiveSession: () => void; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index e813bfdec3..14c4b0d1a4 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -231,6 +231,7 @@ export type AppState = { setWorkspaces: (workspaces: WorkspaceState["items"]) => void; setActiveWorkflow: (workflowId: string | null) => void; setWorkflows: (workflows: WorkflowsState["items"]) => void; + reorderWorkflowItems: (workflowIds: string[]) => void; setWorkflowSnapshot: ( workflowId: string, data: import("./slices/kanban/types").WorkflowSnapshotData, diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index a3fb83a46b..f6d90dd444 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -139,6 +139,7 @@ export type Workflow = { name: string; description?: string | null; workflow_template_id?: string | null; + sort_order?: number; created_at: string; updated_at: string; };