feat: allow moving tasks across workflows (#822)
* feat: allow moving tasks across workflows * fix: address PR review feedback * fix: address remaining PR review feedback * fix: remove unreachable move state event * fix: map task move conflicts correctly * fix: address PR review nitpicks * fix: use neutral task move error message
This commit is contained in:
@@ -255,7 +255,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s
|
||||
zap.Bool("trigger_on_enter", triggerOnEnter))
|
||||
|
||||
if triggerOnEnter {
|
||||
s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description)
|
||||
// Automated transitions always clear review: the agent just completed
|
||||
// a turn, so any pending review from a prior step is stale regardless
|
||||
// of whether the new step has auto_start_agent.
|
||||
s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description, true)
|
||||
} else {
|
||||
// on_turn_start transitions: user is about to send a message, no on_enter needed.
|
||||
// However, we still need to switch the agent profile if the target step requires
|
||||
@@ -396,17 +399,20 @@ func (s *Service) processStepExitAndEnter(ctx context.Context, taskID string, se
|
||||
return
|
||||
}
|
||||
|
||||
s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription)
|
||||
clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent)
|
||||
s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview)
|
||||
}
|
||||
|
||||
// finalizeStepEnter clears review status, reloads the session, and processes on_enter
|
||||
// actions for the target step. Shared by executeStepTransition and processStepExitAndEnter.
|
||||
func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string) {
|
||||
// Clear review status when moving to a new step
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
|
||||
s.logger.Warn("failed to clear session review status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// finalizeStepEnter optionally clears review status, reloads the session, and
|
||||
// processes on_enter actions for the target step. Shared by executeStepTransition
|
||||
// and processStepExitAndEnter.
|
||||
func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) {
|
||||
if clearReview {
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
|
||||
s.logger.Warn("failed to clear session review status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Reload session after on_exit may have changed metadata
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("clears review status on step transition", func(t *testing.T) {
|
||||
t.Run("preserves review status on manual move to non-auto-start step", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
@@ -235,9 +235,48 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
|
||||
|
||||
synctest.Wait()
|
||||
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.ReviewStatus == nil || *updated.ReviewStatus != "pending" {
|
||||
t.Fatalf("expected pending review status to be preserved, got %#v", updated.ReviewStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("clears review status on manual move to auto-start step", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
_ = repo.UpdateSessionReviewStatus(ctx, "s1", "pending")
|
||||
|
||||
stepGetter := newMockStepGetter()
|
||||
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
|
||||
ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0,
|
||||
Events: wfmodels.StepEvents{},
|
||||
}
|
||||
stepGetter.steps["step2"] = &wfmodels.WorkflowStep{
|
||||
ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1,
|
||||
Events: wfmodels.StepEvents{
|
||||
OnEnter: []wfmodels.OnEnterAction{
|
||||
{Type: wfmodels.OnEnterAutoStartAgent},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := createTestService(repo, stepGetter, newMockTaskRepo())
|
||||
svc.handleTaskMovedWithSession(ctx, watcher.TaskMovedEventData{
|
||||
TaskID: "t1",
|
||||
SessionID: "s1",
|
||||
FromStepID: "step1",
|
||||
ToStepID: "step2",
|
||||
TaskDescription: "test task",
|
||||
})
|
||||
|
||||
synctest.Wait()
|
||||
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.ReviewStatus != nil && *updated.ReviewStatus != "" {
|
||||
t.Errorf("expected review status to be cleared, got %q", *updated.ReviewStatus)
|
||||
t.Fatalf("expected review status to be cleared, got %q", *updated.ReviewStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,20 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"})
|
||||
}
|
||||
|
||||
func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) {
|
||||
switch {
|
||||
case isNotFound(err):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task or workflow not found"})
|
||||
case isMoveConflict(err):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case isValidationError(err):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
log.Error("task move failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "task move failed"})
|
||||
}
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -29,6 +43,17 @@ func isNotFound(err error) bool {
|
||||
return strings.Contains(strings.ToLower(err.Error()), "not found")
|
||||
}
|
||||
|
||||
func isMoveConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "active session") ||
|
||||
strings.Contains(msg, "archived tasks cannot be moved") ||
|
||||
strings.Contains(msg, "different workspace") ||
|
||||
strings.Contains(msg, "does not belong to target workflow")
|
||||
}
|
||||
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -299,10 +299,11 @@ func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) {
|
||||
}
|
||||
|
||||
type httpBulkMoveTasksRequest struct {
|
||||
SourceWorkflowID string `json:"source_workflow_id"`
|
||||
SourceStepID string `json:"source_step_id,omitempty"`
|
||||
TargetWorkflowID string `json:"target_workflow_id"`
|
||||
TargetStepID string `json:"target_step_id"`
|
||||
SourceWorkflowID string `json:"source_workflow_id"`
|
||||
SourceStepID string `json:"source_step_id,omitempty"`
|
||||
TargetWorkflowID string `json:"target_workflow_id"`
|
||||
TargetStepID string `json:"target_step_id"`
|
||||
TaskIDs []string `json:"task_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
|
||||
@@ -311,7 +312,15 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" {
|
||||
if body.TargetWorkflowID == "" || body.TargetStepID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target_workflow_id and target_step_id are required"})
|
||||
return
|
||||
}
|
||||
if len(body.TaskIDs) > 0 {
|
||||
h.httpBulkMoveSelectedTasks(c, body)
|
||||
return
|
||||
}
|
||||
if body.SourceWorkflowID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"})
|
||||
return
|
||||
}
|
||||
@@ -328,6 +337,20 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMoveTasksRequest) {
|
||||
result, err := h.service.BulkMoveSelectedTasks(
|
||||
c.Request.Context(),
|
||||
body.TaskIDs,
|
||||
body.TargetWorkflowID,
|
||||
body.TargetStepID,
|
||||
)
|
||||
if err != nil {
|
||||
handleSelectedMoveError(c, h.logger, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
|
||||
}
|
||||
|
||||
type httpTaskRepositoryInput struct {
|
||||
RepositoryID string `json:"repository_id"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
@@ -850,7 +873,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
|
||||
body.WorkflowID, body.WorkflowStepID, body.Position,
|
||||
)
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "task not moved")
|
||||
handleSelectedMoveError(c, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
@@ -25,6 +32,132 @@ func newTestLogger(t *testing.T) *logger.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func TestHandleSelectedMoveError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
wantBodyContains string
|
||||
}{
|
||||
{
|
||||
name: "not found",
|
||||
err: errors.New("task not found: task-1"),
|
||||
want: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "move conflict",
|
||||
err: errors.New("task task-1 cannot be moved: task has an active session (running)"),
|
||||
want: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "bad request validation",
|
||||
err: errors.New("invalid workflow id"),
|
||||
want: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "internal",
|
||||
err: errors.New("failed to count target workflow step tasks: database is locked"),
|
||||
want: http.StatusInternalServerError,
|
||||
wantBodyContains: "task move failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
handleSelectedMoveError(c, log, tc.err)
|
||||
|
||||
assert.Equal(t, tc.want, rec.Code)
|
||||
if tc.wantBodyContains != "" {
|
||||
assert.Contains(t, rec.Body.String(), tc.wantBodyContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type moveTaskConflictRepo struct {
|
||||
mockRepository
|
||||
task *models.Task
|
||||
sessions []*models.TaskSession
|
||||
}
|
||||
|
||||
func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
||||
return m.task, nil
|
||||
}
|
||||
|
||||
func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
|
||||
return m.sessions, nil
|
||||
}
|
||||
|
||||
func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
archivedAt := time.Now().UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
task *models.Task
|
||||
sessions []*models.TaskSession
|
||||
}{
|
||||
{
|
||||
name: "archived task",
|
||||
task: &models.Task{
|
||||
ID: "task-archived",
|
||||
WorkspaceID: "workspace-1",
|
||||
WorkflowID: "wf-source",
|
||||
WorkflowStepID: "step-source",
|
||||
ArchivedAt: &archivedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active session",
|
||||
task: &models.Task{
|
||||
ID: "task-running",
|
||||
WorkspaceID: "workspace-1",
|
||||
WorkflowID: "wf-source",
|
||||
WorkflowStepID: "step-source",
|
||||
},
|
||||
sessions: []*models.TaskSession{{
|
||||
ID: "session-running",
|
||||
TaskID: "task-running",
|
||||
State: models.TaskSessionStateRunning,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := &moveTaskConflictRepo{task: tc.task, sessions: tc.sessions}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: tc.task.ID}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+tc.task.ID+"/move", strings.NewReader(`{
|
||||
"workflow_id": "wf-target",
|
||||
"workflow_step_id": "step-target",
|
||||
"position": 0
|
||||
}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpMoveTask(c)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFreshBranchName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -22,7 +22,7 @@ func (s *Service) PublishTaskUpdated(ctx context.Context, task *models.Task) {
|
||||
}
|
||||
|
||||
// publishTaskEvent publishes task events to the event bus
|
||||
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) {
|
||||
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState, oldWorkflowIDs ...string) {
|
||||
if s.eventBus == nil {
|
||||
return
|
||||
}
|
||||
@@ -66,6 +66,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
|
||||
data["old_state"] = string(*oldState)
|
||||
data["new_state"] = string(task.State)
|
||||
}
|
||||
if len(oldWorkflowIDs) > 0 && oldWorkflowIDs[0] != "" && oldWorkflowIDs[0] != task.WorkflowID {
|
||||
data["old_workflow_id"] = oldWorkflowIDs[0]
|
||||
}
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
@@ -154,12 +157,14 @@ func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]inte
|
||||
|
||||
// publishTaskMovedEvent publishes a task.moved event so the orchestrator can process
|
||||
// on_exit/on_enter actions for the new workflow step.
|
||||
func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromStepID, toStepID, sessionID string) {
|
||||
func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) {
|
||||
if s.eventBus == nil {
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"from_workflow_id": fromWorkflowID,
|
||||
"to_workflow_id": task.WorkflowID,
|
||||
"from_step_id": fromStepID,
|
||||
"to_step_id": toStepID,
|
||||
"session_id": sessionID,
|
||||
|
||||
@@ -228,14 +228,14 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.checkMoveTaskApproval(ctx, id, task.WorkflowStepID, workflowStepID); err != nil {
|
||||
if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldWorkflowID := task.WorkflowID
|
||||
oldStepID := task.WorkflowStepID
|
||||
stepChanged := oldStepID != workflowStepID
|
||||
|
||||
oldState := task.State
|
||||
task.WorkflowID = workflowID
|
||||
task.WorkflowStepID = workflowStepID
|
||||
task.Position = position
|
||||
@@ -252,16 +252,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
|
||||
sessionID = activeSession.ID
|
||||
}
|
||||
|
||||
// Publish state_changed event if state changed, otherwise just updated
|
||||
if oldState != task.State {
|
||||
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
|
||||
} else {
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
||||
}
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID)
|
||||
|
||||
// Publish task.moved event so the orchestrator can process on_exit/on_enter actions
|
||||
if stepChanged {
|
||||
s.publishTaskMovedEvent(ctx, task, oldStepID, workflowStepID, sessionID)
|
||||
s.publishTaskMovedEvent(ctx, task, oldWorkflowID, oldStepID, workflowStepID, sessionID)
|
||||
}
|
||||
|
||||
s.logger.Info("task moved",
|
||||
@@ -288,22 +283,51 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// checkMoveTaskApproval returns an error when the task's primary session has a pending
|
||||
// review and the caller is trying to move it to a different step.
|
||||
func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStepID, targetStepID string) error {
|
||||
if currentStepID == targetStepID {
|
||||
func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string) error {
|
||||
if task.ArchivedAt != nil {
|
||||
return fmt.Errorf("archived tasks cannot be moved")
|
||||
}
|
||||
if err := s.validateMoveSessions(ctx, task.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetWorkflow, err := s.workflows.GetWorkflow(ctx, workflowID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get target workflow: %w", err)
|
||||
}
|
||||
if targetWorkflow.WorkspaceID != task.WorkspaceID {
|
||||
return fmt.Errorf("target workflow is in a different workspace")
|
||||
}
|
||||
if s.workflowStepGetter == nil {
|
||||
return nil
|
||||
}
|
||||
primarySession, err := s.sessions.GetPrimarySessionByTaskID(ctx, taskID)
|
||||
if err != nil || primarySession == nil {
|
||||
return nil
|
||||
targetStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get target workflow step: %w", err)
|
||||
}
|
||||
if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" {
|
||||
return fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes")
|
||||
if targetStep.WorkflowID != workflowID {
|
||||
return fmt.Errorf("target workflow step does not belong to target workflow")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateMoveSessions(ctx context.Context, taskID string) error {
|
||||
sessions, err := s.sessions.ListTaskSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list task sessions: %w", err)
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if isSessionMoveBlocked(session.State) {
|
||||
return fmt.Errorf("task has an active session (%s)", session.State)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSessionMoveBlocked(state models.TaskSessionState) bool {
|
||||
return state == models.TaskSessionStateStarting ||
|
||||
state == models.TaskSessionStateRunning
|
||||
}
|
||||
|
||||
// resolvePrimaryOrActiveSession returns the primary session if it is in an active
|
||||
// state, otherwise falls back to the most recently started active session.
|
||||
func (s *Service) resolvePrimaryOrActiveSession(ctx context.Context, taskID string) *models.TaskSession {
|
||||
@@ -340,6 +364,74 @@ type BulkMoveTasksResult struct {
|
||||
MovedCount int
|
||||
}
|
||||
|
||||
// BulkMoveSelectedTasks moves an explicit task list to a target workflow step.
|
||||
// The list order is treated as the visible UI order; tasks already in the
|
||||
// target step are skipped. Validation reads tasks one at a time because the UI
|
||||
// sends small selected batches; the move is not transactional if task state
|
||||
// changes between pre-validation and an individual MoveTask call.
|
||||
func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) {
|
||||
ids := uniqueTaskIDs(taskIDs)
|
||||
if len(ids) == 0 {
|
||||
return &BulkMoveTasksResult{MovedCount: 0}, nil
|
||||
}
|
||||
|
||||
tasks, err := s.validateSelectedMoveBatch(ctx, ids, targetWorkflowID, targetStepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextPosition, err := s.tasks.CountTasksByWorkflowStep(ctx, targetStepID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count target workflow step tasks: %w", err)
|
||||
}
|
||||
|
||||
movedCount := 0
|
||||
for _, task := range tasks {
|
||||
if task.WorkflowID == targetWorkflowID && task.WorkflowStepID == targetStepID {
|
||||
continue
|
||||
}
|
||||
if _, err := s.MoveTask(ctx, task.ID, targetWorkflowID, targetStepID, nextPosition+movedCount); err != nil {
|
||||
return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err)
|
||||
}
|
||||
movedCount++
|
||||
}
|
||||
|
||||
return &BulkMoveTasksResult{MovedCount: movedCount}, nil
|
||||
}
|
||||
|
||||
func uniqueTaskIDs(taskIDs []string) []string {
|
||||
seen := make(map[string]struct{}, len(taskIDs))
|
||||
result := make([]string, 0, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) ([]*models.Task, error) {
|
||||
tasks := make([]*models.Task, 0, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.WorkflowID != targetWorkflowID || task.WorkflowStepID != targetStepID {
|
||||
if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID); err != nil {
|
||||
return nil, fmt.Errorf("task %s cannot be moved: %w", id, err)
|
||||
}
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step.
|
||||
// If sourceStepID is empty, all tasks in the source workflow are moved.
|
||||
func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) {
|
||||
@@ -361,6 +453,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte
|
||||
|
||||
now := time.Now().UTC()
|
||||
for i, task := range tasks {
|
||||
oldWorkflowID := task.WorkflowID
|
||||
task.WorkflowID = targetWorkflowID
|
||||
task.WorkflowStepID = targetStepID
|
||||
task.Position = i
|
||||
@@ -373,7 +466,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte
|
||||
return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err)
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID)
|
||||
}
|
||||
|
||||
s.logger.Info("bulk moved tasks",
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
type fakeWorkflowStepGetter struct {
|
||||
steps map[string]*wfmodels.WorkflowStep
|
||||
}
|
||||
|
||||
func (f *fakeWorkflowStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
|
||||
if step, ok := f.steps[stepID]; ok {
|
||||
return step, nil
|
||||
}
|
||||
return nil, errStepNotFoundForTest
|
||||
}
|
||||
|
||||
func (f *fakeWorkflowStepGetter) GetNextStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) {
|
||||
for _, step := range f.steps {
|
||||
if step.WorkflowID == workflowID && step.Position == currentPosition+1 {
|
||||
return step, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type testStepNotFound struct{}
|
||||
|
||||
func (testStepNotFound) Error() string { return "step not found" }
|
||||
|
||||
var errStepNotFoundForTest = testStepNotFound{}
|
||||
|
||||
func TestService_MoveTaskRejectsInvalidWorkflowTargets(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
taskID string
|
||||
targetWF string
|
||||
targetSt string
|
||||
}{
|
||||
{
|
||||
name: "step belongs to another workflow",
|
||||
taskID: "task-invalid-step",
|
||||
targetWF: "wf-source",
|
||||
targetSt: "step-target",
|
||||
},
|
||||
{
|
||||
name: "workflow belongs to another workspace",
|
||||
taskID: "task-other-workspace",
|
||||
targetWF: "wf-other-workspace",
|
||||
targetSt: "step-other-workspace",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
createMoveTask(t, ctx, repo, tt.taskID, "wf-source", "step-source", nil)
|
||||
|
||||
_, err := svc.MoveTask(ctx, tt.taskID, tt.targetWF, tt.targetSt, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("expected move to be rejected")
|
||||
}
|
||||
|
||||
task, err := repo.GetTask(ctx, tt.taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" {
|
||||
t.Fatalf("task moved despite validation error: workflow=%s step=%s", task.WorkflowID, task.WorkflowStepID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_MoveTaskAllowsPendingReviewWhenSessionIdle(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-pending-review", "wf-source", "step-source", nil)
|
||||
pending := "pending"
|
||||
createMoveSession(t, ctx, repo, "session-pending-review", "task-pending-review", models.TaskSessionStateWaitingForInput, &pending)
|
||||
|
||||
moved, err := svc.MoveTask(ctx, "task-pending-review", "wf-source", "step-review-target", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("pending review on idle session should not block manual move: %v", err)
|
||||
}
|
||||
if moved.Task.WorkflowStepID != "step-review-target" {
|
||||
t.Fatalf("expected step-review-target, got %s", moved.Task.WorkflowStepID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_MoveTaskRejectsRunningSession(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-running", "wf-source", "step-source", nil)
|
||||
createMoveSession(t, ctx, repo, "session-running", "task-running", models.TaskSessionStateRunning, nil)
|
||||
|
||||
_, err := svc.MoveTask(ctx, "task-running", "wf-source", "step-review-target", 0)
|
||||
if err == nil {
|
||||
t.Fatalf("expected running session move to be rejected")
|
||||
}
|
||||
|
||||
task, err := repo.GetTask(ctx, "task-running")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if task.WorkflowStepID != "step-source" {
|
||||
t.Fatalf("task moved despite running session: %s", task.WorkflowStepID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_MoveTaskRejectsArchivedTask(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
now := time.Now().UTC()
|
||||
createMoveTask(t, ctx, repo, "task-archived", "wf-source", "step-source", &now)
|
||||
|
||||
_, err := svc.MoveTask(ctx, "task-archived", "wf-source", "step-review-target", 0)
|
||||
if err == nil {
|
||||
t.Fatalf("expected archived task move to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_MoveTaskMovedEventIncludesSourceWorkflow(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-cross-workflow", "wf-source", "step-source", nil)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
_, err := svc.MoveTask(ctx, "task-cross-workflow", "wf-target", "step-target", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveTask: %v", err)
|
||||
}
|
||||
|
||||
updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated)
|
||||
updatedData, ok := updatedEvent.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data)
|
||||
}
|
||||
if got := updatedData["old_workflow_id"]; got != "wf-source" {
|
||||
t.Fatalf("old_workflow_id = %v, want wf-source", got)
|
||||
}
|
||||
|
||||
event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved)
|
||||
data, ok := event.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event data type = %T, want map[string]interface{}", event.Data)
|
||||
}
|
||||
if got := data["from_workflow_id"]; got != "wf-source" {
|
||||
t.Fatalf("from_workflow_id = %v, want wf-source", got)
|
||||
}
|
||||
if got := data["to_workflow_id"]; got != "wf-target" {
|
||||
t.Fatalf("to_workflow_id = %v, want wf-target", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_BulkMoveTasksUpdatedEventIncludesSourceWorkflow(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
createMoveTask(t, ctx, repo, "task-bulk-cross-workflow", "wf-source", "step-source", nil)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
_, err := svc.BulkMoveTasks(ctx, "wf-source", "", "wf-target", "step-target")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkMoveTasks: %v", err)
|
||||
}
|
||||
|
||||
updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated)
|
||||
updatedData, ok := updatedEvent.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data)
|
||||
}
|
||||
if got := updatedData["old_workflow_id"]; got != "wf-source" {
|
||||
t.Fatalf("old_workflow_id = %v, want wf-source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_BulkMoveSelectedTasksValidatesBatchBeforeMoving(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-batch-ok", "wf-source", "step-source", nil)
|
||||
createMoveTask(t, ctx, repo, "task-batch-running", "wf-source", "step-source", nil)
|
||||
createMoveSession(t, ctx, repo, "session-batch-running", "task-batch-running", models.TaskSessionStateRunning, nil)
|
||||
|
||||
_, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-ok", "task-batch-running"}, "wf-target", "step-target")
|
||||
if err == nil {
|
||||
t.Fatalf("expected selected batch move to be rejected")
|
||||
}
|
||||
|
||||
for _, id := range []string{"task-batch-ok", "task-batch-running"} {
|
||||
task, err := repo.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask(%s): %v", id, err)
|
||||
}
|
||||
if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" {
|
||||
t.Fatalf("%s moved despite rejected batch: workflow=%s step=%s", id, task.WorkflowID, task.WorkflowStepID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_BulkMoveSelectedTasksSkipsCurrentTargetAndAppendsInOrder(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-target-existing", "wf-target", "step-target", nil)
|
||||
createMoveTask(t, ctx, repo, "task-source-a", "wf-source", "step-source", nil)
|
||||
createMoveTask(t, ctx, repo, "task-target-already", "wf-target", "step-target", nil)
|
||||
createMoveTask(t, ctx, repo, "task-source-b", "wf-source", "step-source", nil)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
result, err := svc.BulkMoveSelectedTasks(
|
||||
ctx,
|
||||
[]string{"task-source-a", "task-target-already", "task-source-b"},
|
||||
"wf-target",
|
||||
"step-target",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BulkMoveSelectedTasks: %v", err)
|
||||
}
|
||||
if result.MovedCount != 2 {
|
||||
t.Fatalf("MovedCount = %d, want 2", result.MovedCount)
|
||||
}
|
||||
|
||||
sourceA, err := repo.GetTask(ctx, "task-source-a")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask(task-source-a): %v", err)
|
||||
}
|
||||
sourceB, err := repo.GetTask(ctx, "task-source-b")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask(task-source-b): %v", err)
|
||||
}
|
||||
if sourceA.Position != 2 || sourceB.Position != 3 {
|
||||
t.Fatalf("positions = (%d, %d), want (2, 3)", sourceA.Position, sourceB.Position)
|
||||
}
|
||||
|
||||
movedEvents := 0
|
||||
for _, event := range eventBus.GetPublishedEvents() {
|
||||
if event.Type == events.TaskMoved {
|
||||
movedEvents++
|
||||
}
|
||||
}
|
||||
if movedEvents != 2 {
|
||||
t.Fatalf("task.moved events = %d, want 2", movedEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func seedMoveWorkflows(t *testing.T, ctx context.Context, repo interface {
|
||||
CreateWorkspace(context.Context, *models.Workspace) error
|
||||
CreateWorkflow(context.Context, *models.Workflow) error
|
||||
}) {
|
||||
t.Helper()
|
||||
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}))
|
||||
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}))
|
||||
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-source", WorkspaceID: "ws-1", Name: "Source"}))
|
||||
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-target", WorkspaceID: "ws-1", Name: "Target"}))
|
||||
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-other-workspace", WorkspaceID: "ws-2", Name: "Other"}))
|
||||
}
|
||||
|
||||
func seedMoveSteps(svc *Service) {
|
||||
svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{
|
||||
"step-source": {ID: "step-source", WorkflowID: "wf-source", Name: "Source", Position: 0},
|
||||
"step-review-target": {ID: "step-review-target", WorkflowID: "wf-source", Name: "Review", Position: 1},
|
||||
"step-target": {ID: "step-target", WorkflowID: "wf-target", Name: "Target", Position: 0},
|
||||
"step-other-workspace": {ID: "step-other-workspace", WorkflowID: "wf-other-workspace", Name: "Other", Position: 0},
|
||||
}})
|
||||
}
|
||||
|
||||
func createMoveTask(t *testing.T, ctx context.Context, repo interface {
|
||||
CreateTask(context.Context, *models.Task) error
|
||||
ArchiveTask(context.Context, string) error
|
||||
}, id, workflowID, stepID string, archivedAt *time.Time) {
|
||||
t.Helper()
|
||||
must(t, repo.CreateTask(ctx, &models.Task{
|
||||
ID: id,
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: workflowID,
|
||||
WorkflowStepID: stepID,
|
||||
Title: id,
|
||||
State: v1.TaskStateTODO,
|
||||
ArchivedAt: archivedAt,
|
||||
}))
|
||||
if archivedAt != nil {
|
||||
must(t, repo.ArchiveTask(ctx, id))
|
||||
}
|
||||
}
|
||||
|
||||
func createMoveSession(t *testing.T, ctx context.Context, repo interface {
|
||||
CreateTaskSession(context.Context, *models.TaskSession) error
|
||||
}, id, taskID string, state models.TaskSessionState, reviewStatus *string) {
|
||||
t.Helper()
|
||||
must(t, repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: id,
|
||||
TaskID: taskID,
|
||||
State: state,
|
||||
IsPrimary: true,
|
||||
ReviewStatus: reviewStatus,
|
||||
}))
|
||||
}
|
||||
|
||||
func findPublishedEvent(t *testing.T, published []*bus.Event, eventType string) *bus.Event {
|
||||
t.Helper()
|
||||
for _, event := range published {
|
||||
if event.Type == eventType {
|
||||
return event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %s not published; got %d events", eventType, len(published))
|
||||
return nil
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { KanbanColumn, WorkflowStep } from "./kanban-column";
|
||||
import { KanbanCardPreview, Task } from "./kanban-card";
|
||||
import { Task } from "./kanban-card";
|
||||
import { KanbanCardPreview } from "./kanban-card-preview";
|
||||
import { MobileColumnTabs } from "./kanban/mobile-column-tabs";
|
||||
import { SwipeableColumns } from "./kanban/swipeable-columns";
|
||||
import { MobileDropTargets } from "./kanban/mobile-drop-targets";
|
||||
|
||||
@@ -69,14 +69,11 @@ function useWorkflowSelection({
|
||||
(workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId,
|
||||
);
|
||||
|
||||
const hasSettingsWorkflow =
|
||||
settings.workflowId &&
|
||||
workspaceWorkflows.some(
|
||||
(workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId,
|
||||
);
|
||||
const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null;
|
||||
const desiredWorkflowId =
|
||||
(hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null;
|
||||
const desiredWorkflowId = resolveDesiredWorkflowId({
|
||||
activeWorkflowId: workflowsState.activeId,
|
||||
settingsWorkflowId: settings.workflowId,
|
||||
workspaceWorkflows,
|
||||
});
|
||||
setActiveWorkflow(desiredWorkflowId);
|
||||
if (!desiredWorkflowId) {
|
||||
store.getState().hydrate({
|
||||
@@ -94,6 +91,28 @@ function useWorkflowSelection({
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveDesiredWorkflowId({
|
||||
activeWorkflowId,
|
||||
settingsWorkflowId,
|
||||
workspaceWorkflows,
|
||||
}: {
|
||||
activeWorkflowId?: string | null;
|
||||
settingsWorkflowId?: string | null;
|
||||
workspaceWorkflows: WorkflowsState["items"];
|
||||
}): string | null {
|
||||
const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden);
|
||||
if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) {
|
||||
return activeWorkflowId;
|
||||
}
|
||||
if (
|
||||
settingsWorkflowId &&
|
||||
visibleWorkflows.some((workflow) => workflow.id === settingsWorkflowId)
|
||||
) {
|
||||
return settingsWorkflowId;
|
||||
}
|
||||
return visibleWorkflows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function useMoveErrorState(router: ReturnType<typeof useRouter>) {
|
||||
const [moveError, setMoveError] = useState<MoveTaskError | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CSS, type Transform } from "@dnd-kit/utilities";
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
|
||||
import { IconAlertCircle, IconArrowsMaximize, IconDots, IconSubtask } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { PRTaskIcon } from "@/components/github/pr-task-icon";
|
||||
import {
|
||||
KanbanCardDropdownMenuItems,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
||||
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
|
||||
import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { needsAction } from "@/lib/utils/needs-action";
|
||||
import type { Task } from "@/components/kanban-card";
|
||||
|
||||
type KanbanCardActionProps = {
|
||||
task: Task;
|
||||
showMaximizeButton?: boolean;
|
||||
onOpenFullPage?: (task: Task) => void;
|
||||
menuEntries: KanbanCardMenuEntry[];
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
};
|
||||
|
||||
type DraggableCardState = {
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners;
|
||||
setNodeRef: (element: HTMLElement | null) => void;
|
||||
transform: Transform | null;
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export type KanbanCardShellProps = KanbanCardActionProps &
|
||||
DraggableCardState & {
|
||||
repositoryNames?: string[];
|
||||
isSelected?: boolean;
|
||||
isMultiSelectMode?: boolean;
|
||||
isPreviewed: boolean;
|
||||
onClick: () => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const REPO_CHIPS_VISIBLE = 2;
|
||||
|
||||
function RepoChipRow({ repoNames }: { repoNames: string[] }) {
|
||||
if (repoNames.length === 0) return null;
|
||||
const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE);
|
||||
const overflow = repoNames.slice(REPO_CHIPS_VISIBLE);
|
||||
const row = (
|
||||
<div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden">
|
||||
{visible.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80">
|
||||
+{overflow.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (repoNames.length <= 1) return row;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{repoNames.map((name) => (
|
||||
<span key={name}>{name}</span>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardBody({
|
||||
task,
|
||||
repoNames,
|
||||
actions,
|
||||
}: {
|
||||
task: Task;
|
||||
repoNames: string[];
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<RepoChipRow repoNames={repoNames} />
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p
|
||||
data-testid="task-card-title"
|
||||
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<PRTaskIcon taskId={task.id} />
|
||||
</div>
|
||||
</div>
|
||||
{task.isRemoteExecutor && (
|
||||
<RemoteCloudTooltip
|
||||
taskId={task.id}
|
||||
sessionId={task.primarySessionId ?? null}
|
||||
fallbackName={task.primaryExecutorName ?? task.primaryExecutorType}
|
||||
/>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<KanbanCardBadges task={task} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardBadges({ task }: { task: Task }) {
|
||||
const parentTitle = useAppStore((s) => {
|
||||
if (!task.parentTaskId) return null;
|
||||
return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null;
|
||||
});
|
||||
|
||||
const showRow =
|
||||
(task.sessionCount && task.sessionCount > 1) ||
|
||||
task.reviewStatus === "changes_requested" ||
|
||||
task.reviewStatus === "pending" ||
|
||||
task.parentTaskId;
|
||||
|
||||
if (!showRow) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
||||
{task.parentTaskId && (
|
||||
<Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0">
|
||||
<IconSubtask className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{parentTitle ?? "Subtask"}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{task.sessionCount && task.sessionCount > 1 && (
|
||||
<Badge variant="secondary" className="text-xs h-5">
|
||||
{task.sessionCount} sessions
|
||||
</Badge>
|
||||
)}
|
||||
{task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
|
||||
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-600">
|
||||
<IconAlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="text-[10px] font-medium">Approval Required</span>
|
||||
</div>
|
||||
)}
|
||||
{task.reviewStatus === "changes_requested" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5"
|
||||
>
|
||||
Changes Requested
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardActions({
|
||||
task,
|
||||
showMaximizeButton,
|
||||
onOpenFullPage,
|
||||
menuEntries,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
}: KanbanCardActionProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving);
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest);
|
||||
const hasKnownSession =
|
||||
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) &&
|
||||
statusIcon}
|
||||
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenFullPage(task);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label="Open full page"
|
||||
title="Open full page"
|
||||
>
|
||||
<IconArrowsMaximize className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<KanbanCardMenu
|
||||
task={task}
|
||||
effectiveMenuOpen={effectiveMenuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
menuEntries={menuEntries}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type KanbanCardMenuProps = KanbanCardActionProps & {
|
||||
effectiveMenuOpen: boolean;
|
||||
setMenuOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function KanbanCardMenu(props: KanbanCardMenuProps) {
|
||||
const { effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props;
|
||||
const { menuEntries } = props;
|
||||
const isProcessing = isDeleting || isArchiving;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={effectiveMenuOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && isProcessing) return;
|
||||
setMenuOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="More options"
|
||||
>
|
||||
<IconDots className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<KanbanCardDropdownMenuItems entries={menuEntries} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardCheckbox({
|
||||
taskId,
|
||||
taskTitle,
|
||||
isSelected,
|
||||
onCheckboxClick,
|
||||
}: {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
isSelected?: boolean;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={onCheckboxClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`task-select-checkbox-${taskId}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!!isSelected}
|
||||
aria-label={`Select task ${taskTitle}`}
|
||||
className="cursor-pointer border-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardActionSlot({
|
||||
isMultiSelectMode,
|
||||
task,
|
||||
showMaximizeButton,
|
||||
onOpenFullPage,
|
||||
menuEntries,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
}: KanbanCardActionProps & { isMultiSelectMode?: boolean }) {
|
||||
if (isMultiSelectMode) return null;
|
||||
return (
|
||||
<KanbanCardActions
|
||||
task={task}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
menuEntries={menuEntries}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardShell({
|
||||
task,
|
||||
repositoryNames,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
isPreviewed,
|
||||
isSelected,
|
||||
isMultiSelectMode,
|
||||
showMaximizeButton,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onClick,
|
||||
onCheckboxClick,
|
||||
onOpenFullPage,
|
||||
menuEntries,
|
||||
}: KanbanCardShellProps) {
|
||||
const showCheckbox = isMultiSelectMode || !!isSelected;
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: "none",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid={`task-card-${task.id}`}
|
||||
className={cn(
|
||||
"group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
|
||||
needsAction(task) && !isSelected && "border-l-2 border-l-amber-500",
|
||||
isDragging && "opacity-50 z-50",
|
||||
isSelected && "ring-1 ring-primary/60 border-primary/60",
|
||||
isPreviewed && !isSelected && "ring-2 ring-primary border-primary",
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...(!isMultiSelectMode ? listeners : {})}
|
||||
{...(!isMultiSelectMode ? attributes : {})}
|
||||
>
|
||||
<CardContent className="px-2 py-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{showCheckbox && (
|
||||
<KanbanCardCheckbox
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
isSelected={isSelected}
|
||||
onCheckboxClick={onCheckboxClick}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<KanbanCardBody
|
||||
task={task}
|
||||
repoNames={repositoryNames ?? []}
|
||||
actions={
|
||||
<KanbanCardActionSlot
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
task={task}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
menuEntries={menuEntries}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu";
|
||||
import {
|
||||
KanbanCardContextMenuItems,
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
export function KanbanCardContextMenu({
|
||||
entries,
|
||||
children,
|
||||
}: {
|
||||
entries: KanbanCardMenuEntry[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isDesktop } = useResponsiveBreakpoint();
|
||||
|
||||
if (!isDesktop) return children;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="block">{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<KanbanCardContextMenuItems entries={entries} />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArrowRight,
|
||||
IconLoader,
|
||||
IconLogicBuffer,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { WorkflowStep } from "@/components/kanban-card";
|
||||
import {
|
||||
stepHasAutoStart,
|
||||
type TaskMoveStep,
|
||||
type TaskMoveWorkflow,
|
||||
} from "@/components/task/task-move-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ItemEntry = {
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
testId?: string;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
type SeparatorEntry = { kind: "separator"; key: string };
|
||||
|
||||
type SubmenuEntry = {
|
||||
kind: "submenu";
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
children: KanbanCardMenuEntry[];
|
||||
};
|
||||
|
||||
export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry;
|
||||
|
||||
export type KanbanCardMoveTargets = {
|
||||
currentWorkflowId: string | null;
|
||||
workflowItems: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
};
|
||||
|
||||
type BuildKanbanCardMenuEntriesArgs = {
|
||||
currentWorkflowId?: string | null;
|
||||
currentStepId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
disabled?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
onEdit?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMoveToStep?: (stepId: string) => void;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
};
|
||||
|
||||
function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) {
|
||||
const hasAutoStart = stepHasAutoStart(step);
|
||||
if (!isCurrent && !hasAutoStart) return null;
|
||||
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>}
|
||||
{hasAutoStart && (
|
||||
<span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildStepEntry(
|
||||
step: TaskMoveStep,
|
||||
currentStepId: string | null | undefined,
|
||||
onSelect: (stepId: string) => void,
|
||||
): KanbanCardMenuEntry {
|
||||
const isCurrent = step.id === currentStepId;
|
||||
return {
|
||||
kind: "item",
|
||||
key: `step-${step.id}`,
|
||||
testId: `task-context-step-${step.id}`,
|
||||
disabled: isCurrent,
|
||||
leading: <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />,
|
||||
label: <span className="flex-1 truncate">{step.title}</span>,
|
||||
trailing: <StepBadges step={step} isCurrent={isCurrent} />,
|
||||
onSelect: () => {
|
||||
if (!isCurrent) onSelect(step.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMoveToCurrentWorkflowSubmenu({
|
||||
steps,
|
||||
currentStepId,
|
||||
disabled,
|
||||
onMoveToStep,
|
||||
}: {
|
||||
steps: TaskMoveStep[];
|
||||
currentStepId?: string | null;
|
||||
disabled?: boolean;
|
||||
onMoveToStep?: (stepId: string) => void;
|
||||
}): KanbanCardMenuEntry | null {
|
||||
if (!onMoveToStep || steps.length <= 1) return null;
|
||||
return {
|
||||
kind: "submenu",
|
||||
key: "move-to",
|
||||
testId: "task-context-move-to",
|
||||
icon: <IconArrowRight className="mr-2 h-4 w-4" />,
|
||||
label: "Move to",
|
||||
disabled,
|
||||
className: "w-48",
|
||||
children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowTargetEntry({
|
||||
workflow,
|
||||
steps,
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
}: {
|
||||
workflow: TaskMoveWorkflow;
|
||||
steps: TaskMoveStep[];
|
||||
disabled?: boolean;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
}): KanbanCardMenuEntry {
|
||||
if (steps.length === 0 || !onSendToWorkflow) {
|
||||
return {
|
||||
kind: "item",
|
||||
key: `workflow-${workflow.id}`,
|
||||
testId: `task-context-workflow-${workflow.id}`,
|
||||
disabled: true,
|
||||
label: <span className="flex-1 truncate">{workflow.name}</span>,
|
||||
trailing: (
|
||||
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
|
||||
No steps
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "submenu",
|
||||
key: `workflow-${workflow.id}`,
|
||||
testId: `task-context-workflow-${workflow.id}`,
|
||||
label: <span className="truncate">{workflow.name}</span>,
|
||||
disabled,
|
||||
className: "w-48",
|
||||
children: steps.map((step) =>
|
||||
buildStepEntry(step, null, (stepId) => onSendToWorkflow(workflow.id, stepId)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSendToWorkflowSubmenu({
|
||||
currentWorkflowId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
}: {
|
||||
currentWorkflowId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
disabled?: boolean;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
}): KanbanCardMenuEntry | null {
|
||||
const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId);
|
||||
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
|
||||
return {
|
||||
kind: "submenu",
|
||||
key: "send-to-workflow",
|
||||
testId: "task-context-send-to-workflow",
|
||||
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
|
||||
label: "Send to workflow",
|
||||
disabled,
|
||||
className: "w-56",
|
||||
children: targets.map((workflow) =>
|
||||
buildWorkflowTargetEntry({
|
||||
workflow,
|
||||
steps: stepsByWorkflowId[workflow.id] ?? [],
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKanbanCardMenuEntries({
|
||||
currentWorkflowId,
|
||||
currentStepId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onMoveToStep,
|
||||
onSendToWorkflow,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
|
||||
const isProcessing = Boolean(disabled || isDeleting || isArchiving);
|
||||
const entries: KanbanCardMenuEntry[] = [
|
||||
{
|
||||
kind: "item",
|
||||
key: "edit",
|
||||
icon: <IconPencil className="mr-2 h-4 w-4" />,
|
||||
label: "Edit",
|
||||
disabled: isProcessing || !onEdit,
|
||||
onSelect: onEdit,
|
||||
},
|
||||
];
|
||||
|
||||
const moveToEntry = buildMoveToCurrentWorkflowSubmenu({
|
||||
steps: currentSteps,
|
||||
currentStepId,
|
||||
disabled: isProcessing,
|
||||
onMoveToStep,
|
||||
});
|
||||
if (moveToEntry) entries.push(moveToEntry);
|
||||
|
||||
const sendToEntry = buildSendToWorkflowSubmenu({
|
||||
currentWorkflowId,
|
||||
workflows: visibleWorkflows,
|
||||
stepsByWorkflowId,
|
||||
disabled: isProcessing,
|
||||
onSendToWorkflow,
|
||||
});
|
||||
if (sendToEntry) entries.push(sendToEntry);
|
||||
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "archive",
|
||||
icon: isArchiving ? (
|
||||
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconArchive className="mr-2 h-4 w-4" />
|
||||
),
|
||||
label: "Archive",
|
||||
disabled: isProcessing || !onArchive,
|
||||
onSelect: onArchive,
|
||||
});
|
||||
|
||||
entries.push({ kind: "separator", key: "delete-separator" });
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "delete",
|
||||
icon: isDeleting ? (
|
||||
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
),
|
||||
label: "Delete",
|
||||
destructive: true,
|
||||
disabled: isProcessing || !onDelete,
|
||||
onSelect: onDelete,
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function useKanbanCardMoveTargets(
|
||||
taskId: string,
|
||||
steps?: WorkflowStep[],
|
||||
): KanbanCardMoveTargets {
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
|
||||
const currentWorkflowId = useMemo(() => {
|
||||
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
|
||||
if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId;
|
||||
}
|
||||
return null;
|
||||
}, [snapshots, taskId]);
|
||||
|
||||
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
|
||||
const current = workflows.find((workflow) => workflow.id === currentWorkflowId);
|
||||
return workflows
|
||||
.filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden }));
|
||||
}, [workflows, currentWorkflowId]);
|
||||
|
||||
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
|
||||
const result: Record<string, TaskMoveStep[]> = {};
|
||||
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
|
||||
result[workflowId] = snapshot.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
if (currentWorkflowId && steps) {
|
||||
result[currentWorkflowId] = steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color,
|
||||
events: step.events,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}, [snapshots, currentWorkflowId, steps]);
|
||||
|
||||
return { currentWorkflowId, workflowItems, stepsByWorkflowId };
|
||||
}
|
||||
|
||||
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<ContextEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
onSelect={() => {
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <DropdownMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<DropdownEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<ContextEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<DropdownEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { KanbanCardBody } from "@/components/kanban-card-content";
|
||||
import { resolveTaskRepositoryNames, type Task } from "@/components/kanban-card";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
|
||||
function KanbanCardPreviewLayout({
|
||||
task,
|
||||
repositoryNames,
|
||||
}: {
|
||||
task: Task;
|
||||
repositoryNames: string[];
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className="w-full py-0 cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border"
|
||||
>
|
||||
<CardContent className="px-2 py-1">
|
||||
<KanbanCardBody task={task} repoNames={repositoryNames} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardPreview({ task }: { task: Task }) {
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const repositoryNames = useMemo(
|
||||
() =>
|
||||
resolveTaskRepositoryNames(
|
||||
task,
|
||||
Object.values(repositoriesByWorkspace).flat() as Repository[],
|
||||
),
|
||||
[repositoriesByWorkspace, task],
|
||||
);
|
||||
|
||||
return <KanbanCardPreviewLayout task={task} repositoryNames={repositoryNames} />;
|
||||
}
|
||||
+146
-464
@@ -1,40 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
|
||||
import { KanbanCardShell } from "@/components/kanban-card-content";
|
||||
import {
|
||||
IconDots,
|
||||
IconArrowsMaximize,
|
||||
IconLoader,
|
||||
IconAlertCircle,
|
||||
IconSubtask,
|
||||
} from "@tabler/icons-react";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import type { TaskState, Repository } from "@/lib/types/http";
|
||||
import { cn, getRepositoryDisplayName } from "@/lib/utils";
|
||||
import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
|
||||
import { needsAction } from "@/lib/utils/needs-action";
|
||||
buildKanbanCardMenuEntries,
|
||||
useKanbanCardMoveTargets,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { PRTaskIcon } from "@/components/github/pr-task-icon";
|
||||
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { getRepositoryDisplayName } from "@/lib/utils";
|
||||
import type { Repository, TaskState } from "@/lib/types/http";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -46,7 +25,6 @@ export interface Task {
|
||||
repositoryId?: string;
|
||||
/** All repositories linked to the task; used to render a "+N" chip for multi-repo. */
|
||||
repositories?: Array<{ id: string; repository_id: string; position: number }>;
|
||||
// Workflow fields
|
||||
sessionCount?: number | null;
|
||||
primarySessionId?: string | null;
|
||||
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
|
||||
@@ -63,6 +41,12 @@ export interface WorkflowStep {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
events?: {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
@@ -80,383 +64,100 @@ interface KanbanCardProps {
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
isSelected?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
}
|
||||
|
||||
const REPO_CHIPS_VISIBLE = 2;
|
||||
|
||||
function RepoChipRow({ repoNames }: { repoNames: string[] }) {
|
||||
if (repoNames.length === 0) return null;
|
||||
const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE);
|
||||
const overflow = repoNames.slice(REPO_CHIPS_VISIBLE);
|
||||
const row = (
|
||||
<div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden">
|
||||
{visible.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80">
|
||||
+{overflow.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (repoNames.length <= 1) return row;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{repoNames.map((name) => (
|
||||
<span key={name}>{name}</span>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardBody({
|
||||
function useKanbanCardMenus({
|
||||
task,
|
||||
repoNames,
|
||||
actions,
|
||||
}: {
|
||||
task: Task;
|
||||
repoNames: string[];
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<RepoChipRow repoNames={repoNames} />
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p
|
||||
data-testid="task-card-title"
|
||||
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<PRTaskIcon taskId={task.id} />
|
||||
</div>
|
||||
</div>
|
||||
{task.isRemoteExecutor && (
|
||||
<RemoteCloudTooltip
|
||||
taskId={task.id}
|
||||
sessionId={task.primarySessionId ?? null}
|
||||
fallbackName={task.primaryExecutorName ?? task.primaryExecutorType}
|
||||
/>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<KanbanCardBadges task={task} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardBadges({ task }: { task: Task }) {
|
||||
const parentTitle = useAppStore((s) => {
|
||||
if (!task.parentTaskId) return null;
|
||||
return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null;
|
||||
});
|
||||
|
||||
const showRow =
|
||||
(task.sessionCount && task.sessionCount > 1) ||
|
||||
task.reviewStatus === "changes_requested" ||
|
||||
task.reviewStatus === "pending" ||
|
||||
task.parentTaskId;
|
||||
|
||||
if (!showRow) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
||||
{task.parentTaskId && (
|
||||
<Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0">
|
||||
<IconSubtask className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{parentTitle ?? "Subtask"}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{task.sessionCount && task.sessionCount > 1 && (
|
||||
<Badge variant="secondary" className="text-xs h-5">
|
||||
{task.sessionCount} sessions
|
||||
</Badge>
|
||||
)}
|
||||
{task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
|
||||
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-600">
|
||||
<IconAlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="text-[10px] font-medium">Approval Required</span>
|
||||
</div>
|
||||
)}
|
||||
{task.reviewStatus === "changes_requested" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5"
|
||||
>
|
||||
Changes Requested
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardLayout({
|
||||
task,
|
||||
repositoryNames,
|
||||
className,
|
||||
}: KanbanCardProps & { className?: string }) {
|
||||
return (
|
||||
<Card size="sm" className={cn("w-full py-0", className)}>
|
||||
<CardContent className="px-2 py-1">
|
||||
<KanbanCardBody task={task} repoNames={repositoryNames ?? []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardActions({
|
||||
task,
|
||||
showMaximizeButton,
|
||||
onOpenFullPage,
|
||||
steps,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
isSelected,
|
||||
selectedIds,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onMove,
|
||||
steps,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
}: Pick<
|
||||
KanbanCardProps,
|
||||
| "task"
|
||||
| "showMaximizeButton"
|
||||
| "onOpenFullPage"
|
||||
| "steps"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
| "isSelected"
|
||||
| "selectedIds"
|
||||
| "onEdit"
|
||||
| "onDelete"
|
||||
| "onArchive"
|
||||
| "onMove"
|
||||
| "steps"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
>) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving);
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest);
|
||||
const hasKnownSession =
|
||||
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) &&
|
||||
statusIcon}
|
||||
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenFullPage(task);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label="Open full page"
|
||||
title="Open full page"
|
||||
>
|
||||
<IconArrowsMaximize className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<KanbanCardMenu
|
||||
task={task}
|
||||
effectiveMenuOpen={effectiveMenuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onMove={onMove}
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToSubmenu({
|
||||
task,
|
||||
steps,
|
||||
disabled,
|
||||
onMove,
|
||||
}: {
|
||||
task: Task;
|
||||
steps: WorkflowStep[];
|
||||
disabled?: boolean;
|
||||
onMove: (task: Task, targetStepId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
Move to
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{steps
|
||||
.filter((col) => col.id !== task.workflowStepId)
|
||||
.map((col) => (
|
||||
<DropdownMenuItem
|
||||
key={col.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMove(task, col.id);
|
||||
}}
|
||||
>
|
||||
<div className={cn("w-2 h-2 rounded-full mr-2", col.color)} />
|
||||
{col.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
type KanbanCardMenuProps = {
|
||||
task: Task;
|
||||
effectiveMenuOpen: boolean;
|
||||
setMenuOpen: (open: boolean) => void;
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
onEdit?: (task: Task) => void;
|
||||
onDelete?: (task: Task) => void;
|
||||
onArchive?: (task: Task) => void;
|
||||
onMove?: (task: Task, targetStepId: string) => void;
|
||||
steps?: WorkflowStep[];
|
||||
};
|
||||
|
||||
function KanbanCardMenu(props: KanbanCardMenuProps) {
|
||||
const { task, effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props;
|
||||
const { onEdit, onDelete, onArchive, onMove, steps } = props;
|
||||
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const isProcessing = isDeleting || isArchiving;
|
||||
const disabled = Boolean(isDeleting || isArchiving);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
open={effectiveMenuOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && isProcessing) return;
|
||||
setMenuOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="More options"
|
||||
>
|
||||
<IconDots className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(task);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{steps && steps.length > 1 && onMove && (
|
||||
<MoveToSubmenu task={task} steps={steps} disabled={isProcessing} onMove={onMove} />
|
||||
)}
|
||||
{onArchive && (
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowArchiveConfirm(true);
|
||||
}}
|
||||
>
|
||||
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TaskDeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
taskTitle={task.title}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={() => onDelete?.(task)}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
open={showArchiveConfirm}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={() => {
|
||||
onArchive?.(task);
|
||||
setShowArchiveConfirm(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => {
|
||||
void moveTasks(taskIds, workflowId, stepId).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
|
||||
function KanbanCardCheckbox({
|
||||
taskId,
|
||||
taskTitle,
|
||||
isSelected,
|
||||
onCheckboxClick,
|
||||
}: {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
isSelected?: boolean;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={onCheckboxClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`task-select-checkbox-${taskId}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!!isSelected}
|
||||
aria-label={`Select task ${taskTitle}`}
|
||||
className="cursor-pointer border-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const moveToStepFromDropdown = (stepId: string) => {
|
||||
if (onMove) {
|
||||
onMove(task, stepId);
|
||||
return;
|
||||
}
|
||||
if (moveTargets.currentWorkflowId) {
|
||||
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
|
||||
const moveSelectedToStep = (stepId: string) => {
|
||||
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
|
||||
onMove(task, stepId);
|
||||
return;
|
||||
}
|
||||
if (!moveTargets.currentWorkflowId) return;
|
||||
runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId);
|
||||
};
|
||||
|
||||
const menuBase = {
|
||||
currentWorkflowId: moveTargets.currentWorkflowId,
|
||||
currentStepId: task.workflowStepId,
|
||||
workflows: moveTargets.workflowItems,
|
||||
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
|
||||
disabled,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onEdit: onEdit ? () => onEdit(task) : undefined,
|
||||
onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined,
|
||||
onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
dropdownMenuEntries: buildKanbanCardMenuEntries({
|
||||
...menuBase,
|
||||
onMoveToStep: moveToStepFromDropdown,
|
||||
onSendToWorkflow: (workflowId, stepId) => {
|
||||
runMoveTasks([task.id], workflowId, stepId);
|
||||
},
|
||||
}),
|
||||
contextMenuEntries: buildKanbanCardMenuEntries({
|
||||
...menuBase,
|
||||
onMoveToStep: moveSelectedToStep,
|
||||
onSendToWorkflow: (workflowId, stepId) => {
|
||||
runMoveTasks(selectedTaskIds, workflowId, stepId);
|
||||
},
|
||||
}),
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
showArchiveConfirm,
|
||||
setShowArchiveConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
export function KanbanCard({
|
||||
@@ -473,6 +174,7 @@ export function KanbanCard({
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
isSelected,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
isMultiSelectMode,
|
||||
}: KanbanCardProps) {
|
||||
@@ -480,14 +182,26 @@ export function KanbanCard({
|
||||
id: task.id,
|
||||
disabled: isMultiSelectMode,
|
||||
});
|
||||
|
||||
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: "none",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
const {
|
||||
dropdownMenuEntries,
|
||||
contextMenuEntries,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
showArchiveConfirm,
|
||||
setShowArchiveConfirm,
|
||||
} = useKanbanCardMenus({
|
||||
task,
|
||||
steps,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
isSelected,
|
||||
selectedIds,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onMove,
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -502,76 +216,44 @@ export function KanbanCard({
|
||||
onToggleSelect?.(task.id);
|
||||
};
|
||||
|
||||
const showCheckbox = isMultiSelectMode || !!isSelected;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid={`task-card-${task.id}`}
|
||||
className={cn(
|
||||
"group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
|
||||
needsAction(task) && !isSelected && "border-l-2 border-l-amber-500",
|
||||
isDragging && "opacity-50 z-50",
|
||||
isSelected && "ring-1 ring-primary/60 border-primary/60",
|
||||
isPreviewed && !isSelected && "ring-2 ring-primary border-primary",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...(!isMultiSelectMode ? listeners : {})}
|
||||
{...(!isMultiSelectMode ? attributes : {})}
|
||||
>
|
||||
<CardContent className="px-2 py-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{showCheckbox && (
|
||||
<KanbanCardCheckbox
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
isSelected={isSelected}
|
||||
onCheckboxClick={handleCheckboxClick}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<KanbanCardBody
|
||||
task={task}
|
||||
repoNames={repositoryNames ?? []}
|
||||
actions={
|
||||
!isMultiSelectMode ? (
|
||||
<KanbanCardActions
|
||||
task={task}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onMove={onMove}
|
||||
steps={steps}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardPreview({ task }: KanbanCardProps) {
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const repositoryNames = useMemo(
|
||||
() => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()),
|
||||
[repositoriesByWorkspace, task],
|
||||
);
|
||||
|
||||
return (
|
||||
<KanbanCardLayout
|
||||
task={task}
|
||||
repositoryNames={repositoryNames}
|
||||
className="cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border"
|
||||
/>
|
||||
<>
|
||||
<KanbanCardContextMenu entries={contextMenuEntries}>
|
||||
<KanbanCardShell
|
||||
task={task}
|
||||
repositoryNames={repositoryNames}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
setNodeRef={setNodeRef}
|
||||
transform={transform}
|
||||
isDragging={isDragging}
|
||||
isPreviewed={isPreviewed}
|
||||
isSelected={isSelected}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
menuEntries={dropdownMenuEntries}
|
||||
onClick={handleClick}
|
||||
onCheckboxClick={handleCheckboxClick}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
/>
|
||||
</KanbanCardContextMenu>
|
||||
<TaskDeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
taskTitle={task.title}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={() => onDelete?.(task)}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
open={showArchiveConfirm}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={() => onArchive?.(task)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export function KanbanColumn({
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
isArchiving={archivingTaskId === task.id}
|
||||
isSelected={selectedIds?.has(task.id)}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { KanbanColumn } from "@/components/kanban-column";
|
||||
import { KanbanCardPreview, type Task } from "@/components/kanban-card";
|
||||
import { type Task } from "@/components/kanban-card";
|
||||
import { KanbanCardPreview } from "@/components/kanban-card-preview";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { IconArrowRight, IconLogicBuffer } from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TaskMoveStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
color?: string | null;
|
||||
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
|
||||
};
|
||||
|
||||
export type TaskMoveWorkflow = {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
type TaskMoveContextMenuItemsProps = {
|
||||
currentWorkflowId?: string | null;
|
||||
currentStepId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
disabled?: boolean;
|
||||
showSeparator?: boolean;
|
||||
onMoveToStep?: (stepId: string) => void;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
};
|
||||
|
||||
export function stepHasAutoStart(step: TaskMoveStep) {
|
||||
return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false;
|
||||
}
|
||||
|
||||
function StepMenuItem({
|
||||
step,
|
||||
currentStepId,
|
||||
onSelect,
|
||||
}: {
|
||||
step: TaskMoveStep;
|
||||
currentStepId?: string | null;
|
||||
onSelect: (stepId: string) => void;
|
||||
}) {
|
||||
const isCurrent = step.id === currentStepId;
|
||||
const hasAutoStart = stepHasAutoStart(step);
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={step.id}
|
||||
data-testid={`task-context-step-${step.id}`}
|
||||
disabled={isCurrent}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isCurrent) onSelect(step.id);
|
||||
}}
|
||||
>
|
||||
<span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />
|
||||
<span className="flex-1 truncate">{step.title}</span>
|
||||
{(isCurrent || hasAutoStart) && (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>}
|
||||
{hasAutoStart && (
|
||||
<span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToCurrentWorkflowSubmenu({
|
||||
steps,
|
||||
currentStepId,
|
||||
disabled,
|
||||
onMoveToStep,
|
||||
}: {
|
||||
steps: TaskMoveStep[];
|
||||
currentStepId?: string | null;
|
||||
disabled?: boolean;
|
||||
onMoveToStep?: (stepId: string) => void;
|
||||
}) {
|
||||
if (!onMoveToStep || steps.length <= 1) return null;
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid="task-context-move-to" disabled={disabled}>
|
||||
<IconArrowRight className="mr-2 h-4 w-4" />
|
||||
Move to
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
{steps.map((step) => (
|
||||
<StepMenuItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
currentStepId={currentStepId}
|
||||
onSelect={onMoveToStep}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowTargetItem({
|
||||
workflow,
|
||||
steps,
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
}: {
|
||||
workflow: TaskMoveWorkflow;
|
||||
steps: TaskMoveStep[];
|
||||
disabled?: boolean;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
}) {
|
||||
if (steps.length === 0 || !onSendToWorkflow) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
data-testid={`task-context-workflow-${workflow.id}`}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="flex-1 truncate">{workflow.name}</span>
|
||||
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
|
||||
No steps
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger
|
||||
data-testid={`task-context-workflow-${workflow.id}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate">{workflow.name}</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
{steps.map((step) => (
|
||||
<StepMenuItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
onSelect={(stepId) => onSendToWorkflow(workflow.id, stepId)}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function SendToWorkflowSubmenu({
|
||||
currentWorkflowId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
}: {
|
||||
currentWorkflowId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
disabled?: boolean;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
}) {
|
||||
const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId);
|
||||
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid="task-context-send-to-workflow" disabled={disabled}>
|
||||
<IconLogicBuffer className="mr-2 h-4 w-4" />
|
||||
Send to workflow
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-56">
|
||||
{targets.map((workflow) => (
|
||||
<WorkflowTargetItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
steps={stepsByWorkflowId[workflow.id] ?? []}
|
||||
disabled={disabled}
|
||||
onSendToWorkflow={onSendToWorkflow}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskMoveContextMenuItems({
|
||||
currentWorkflowId,
|
||||
currentStepId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
showSeparator = true,
|
||||
onMoveToStep,
|
||||
onSendToWorkflow,
|
||||
}: TaskMoveContextMenuItemsProps) {
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
|
||||
const hasSameWorkflowMove = Boolean(onMoveToStep && currentSteps.length > 1);
|
||||
const hasCrossWorkflowMove = Boolean(
|
||||
onSendToWorkflow &&
|
||||
currentWorkflowId &&
|
||||
visibleWorkflows.some((workflow) => workflow.id !== currentWorkflowId),
|
||||
);
|
||||
|
||||
if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
<MoveToCurrentWorkflowSubmenu
|
||||
steps={currentSteps}
|
||||
currentStepId={currentStepId}
|
||||
disabled={disabled}
|
||||
onMoveToStep={onMoveToStep}
|
||||
/>
|
||||
<SendToWorkflowSubmenu
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
workflows={visibleWorkflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
disabled={disabled}
|
||||
onSendToWorkflow={onSendToWorkflow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,13 @@ type TaskSessionSidebarProps = {
|
||||
workflowId: string | null;
|
||||
};
|
||||
|
||||
type StepInfo = { id: string; title: string; color: string; position: number };
|
||||
type StepInfo = {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
position: number;
|
||||
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
|
||||
};
|
||||
type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
|
||||
|
||||
function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarItem {
|
||||
@@ -221,6 +227,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
|
||||
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId);
|
||||
@@ -308,6 +315,9 @@ function useSidebarData(workspaceId: string | null) {
|
||||
isLoadingWorkflow,
|
||||
tasksWithRepositories,
|
||||
primarySessionIds,
|
||||
workflows: workflows
|
||||
.filter((workflow) => !workspaceId || workflow.workspaceId === workspaceId)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -521,6 +531,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
stepsByWorkflowId,
|
||||
workflows,
|
||||
isLoadingWorkflow,
|
||||
tasksWithRepositories,
|
||||
primarySessionIds,
|
||||
@@ -569,6 +580,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
<PanelBody className="space-y-4 p-0">
|
||||
<TaskSwitcher
|
||||
grouped={grouped}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { cloneElement, isValidElement, memo, useState } from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconArrowRight,
|
||||
IconPencil,
|
||||
IconCopy,
|
||||
IconArchive,
|
||||
@@ -15,15 +14,17 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskItem } from "./task-item";
|
||||
import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view";
|
||||
import {
|
||||
TaskMoveContextMenuItems,
|
||||
type TaskMoveWorkflow,
|
||||
} from "@/components/task/task-move-context-menu";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
|
||||
export type TaskSwitcherItem = {
|
||||
id: string;
|
||||
@@ -54,10 +55,16 @@ export type TaskSwitcherItem = {
|
||||
issueInfo?: { url: string; number: number };
|
||||
};
|
||||
|
||||
type StepDef = { id: string; title: string; color?: string };
|
||||
type StepDef = {
|
||||
id: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
|
||||
};
|
||||
|
||||
type TaskSwitcherProps = {
|
||||
grouped: GroupedSidebarList;
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
@@ -132,6 +139,7 @@ type TaskRowProps = {
|
||||
task: TaskSwitcherItem;
|
||||
isSubTask?: boolean;
|
||||
subtaskToggle?: SubtaskToggleInfo;
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
@@ -147,6 +155,7 @@ function TaskRow({
|
||||
task,
|
||||
isSubTask,
|
||||
subtaskToggle,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
@@ -162,6 +171,8 @@ function TaskRow({
|
||||
return (
|
||||
<TaskItemWithContextMenu
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={taskSteps}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
@@ -200,6 +211,7 @@ function TaskRow({
|
||||
function GroupSection({
|
||||
group,
|
||||
subTasksByParentId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
@@ -217,6 +229,7 @@ function GroupSection({
|
||||
}: {
|
||||
group: SidebarGroup;
|
||||
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
@@ -238,6 +251,7 @@ function GroupSection({
|
||||
);
|
||||
const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []);
|
||||
const rowProps = {
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
@@ -287,6 +301,8 @@ function GroupSection({
|
||||
|
||||
function TaskItemWithContextMenu({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
children,
|
||||
onRenameTask,
|
||||
@@ -296,6 +312,8 @@ function TaskItemWithContextMenu({
|
||||
isDeleting,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
children: React.ReactElement<{ menuOpen?: boolean }>;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
@@ -306,7 +324,12 @@ function TaskItemWithContextMenu({
|
||||
}) {
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const menuOpen = contextOpen || isDeleting === true;
|
||||
const closeMenu = () => {
|
||||
setContextOpen(false);
|
||||
setMenuKey((k) => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu key={menuKey} onOpenChange={setContextOpen}>
|
||||
@@ -330,15 +353,26 @@ function TaskItemWithContextMenu({
|
||||
Archive
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onMoveToStep && task.workflowId && steps && steps.length > 0 && (
|
||||
<MoveToStepSubmenu
|
||||
steps={steps}
|
||||
{task.workflowId && (
|
||||
<TaskMoveContextMenuItems
|
||||
currentWorkflowId={task.workflowId}
|
||||
currentStepId={task.workflowStepId}
|
||||
disabled={isDeleting}
|
||||
onSelect={(stepId) => {
|
||||
setContextOpen(false);
|
||||
setMenuKey((k) => k + 1);
|
||||
onMoveToStep(task.id, task.workflowId!, stepId);
|
||||
workflows={workflows ?? []}
|
||||
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
|
||||
disabled={isDeleting || task.isArchived}
|
||||
onMoveToStep={
|
||||
onMoveToStep
|
||||
? (stepId) => {
|
||||
closeMenu();
|
||||
onMoveToStep(task.id, task.workflowId!, stepId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSendToWorkflow={(workflowId, stepId) => {
|
||||
closeMenu();
|
||||
void moveTasks([task.id], workflowId, stepId).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -364,48 +398,6 @@ function TaskItemWithContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToStepSubmenu({
|
||||
steps,
|
||||
currentStepId,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
steps: StepDef[];
|
||||
currentStepId?: string;
|
||||
disabled?: boolean;
|
||||
onSelect: (stepId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={disabled}>
|
||||
<IconArrowRight className="mr-2 h-4 w-4" />
|
||||
Move to
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
{steps.map((step) => (
|
||||
<ContextMenuItem
|
||||
key={step.id}
|
||||
disabled={step.id === currentStepId}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(step.id);
|
||||
}}
|
||||
>
|
||||
<span className={cn("block h-2 w-2 rounded-full shrink-0", step.color)} />
|
||||
<span className="flex-1 truncate">{step.title}</span>
|
||||
{step.id === currentStepId && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">Current</span>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function cloneWithMenuOpen(
|
||||
children: React.ReactElement<{ menuOpen?: boolean }>,
|
||||
menuOpen: boolean,
|
||||
@@ -416,6 +408,7 @@ function cloneWithMenuOpen(
|
||||
|
||||
export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
grouped,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
@@ -450,6 +443,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
key={group.key}
|
||||
group={group}
|
||||
subTasksByParentId={grouped.subTasksByParentId}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
|
||||
@@ -59,6 +59,58 @@ export class KanbanPage {
|
||||
});
|
||||
}
|
||||
|
||||
contextMoveTo(): Locator {
|
||||
return this.page.getByTestId("task-context-move-to");
|
||||
}
|
||||
|
||||
contextSendToWorkflow(): Locator {
|
||||
return this.page.getByTestId("task-context-send-to-workflow");
|
||||
}
|
||||
|
||||
contextWorkflow(workflowId: string): Locator {
|
||||
return this.page.getByTestId(`task-context-workflow-${workflowId}`);
|
||||
}
|
||||
|
||||
contextStep(stepId: string): Locator {
|
||||
return this.page.getByTestId(`task-context-step-${stepId}`);
|
||||
}
|
||||
|
||||
contextAutoStartStep(stepId: string): Locator {
|
||||
return this.page.getByTestId(`task-context-step-autostart-${stepId}`);
|
||||
}
|
||||
|
||||
async openTaskContextMenu(taskId: string) {
|
||||
const card = this.taskCard(taskId);
|
||||
await card.waitFor({ state: "visible" });
|
||||
await card.click({ button: "right" });
|
||||
}
|
||||
|
||||
async openTaskActionsMenu(taskId: string) {
|
||||
const card = this.taskCard(taskId);
|
||||
await card.waitFor({ state: "visible" });
|
||||
await card.getByLabel("More options").click();
|
||||
}
|
||||
|
||||
async moveTaskWithinWorkflow(taskId: string, stepId: string) {
|
||||
await this.openTaskContextMenu(taskId);
|
||||
await this.contextMoveTo().hover();
|
||||
await this.contextStep(stepId).click();
|
||||
}
|
||||
|
||||
async sendTaskToWorkflow(taskId: string, workflowId: string, stepId: string) {
|
||||
await this.openTaskContextMenu(taskId);
|
||||
await this.contextSendToWorkflow().hover();
|
||||
await this.contextWorkflow(workflowId).hover();
|
||||
await this.contextStep(stepId).click();
|
||||
}
|
||||
|
||||
async sendTaskToWorkflowFromActions(taskId: string, workflowId: string, stepId: string) {
|
||||
await this.openTaskActionsMenu(taskId);
|
||||
await this.contextSendToWorkflow().hover();
|
||||
await this.contextWorkflow(workflowId).hover();
|
||||
await this.contextStep(stepId).click();
|
||||
}
|
||||
|
||||
async enableMultiSelect() {
|
||||
await this.multiSelectToggle.first().waitFor({ state: "visible" });
|
||||
const isEnabled = await this.page
|
||||
|
||||
@@ -150,6 +150,29 @@ export class SessionPage {
|
||||
return this.sidebar.getByText(title, { exact: false });
|
||||
}
|
||||
|
||||
sidebarTaskItem(title: string): Locator {
|
||||
return this.sidebar.getByTestId("sidebar-task-item").filter({
|
||||
has: this.page.getByText(title, { exact: false }),
|
||||
});
|
||||
}
|
||||
|
||||
async openSidebarTaskContextMenu(title: string): Promise<void> {
|
||||
const taskRow = this.sidebarTaskItem(title).first();
|
||||
await taskRow.waitFor({ state: "visible" });
|
||||
await taskRow.click({ button: "right" });
|
||||
}
|
||||
|
||||
async sendSidebarTaskToWorkflow(
|
||||
title: string,
|
||||
workflowId: string,
|
||||
stepId: string,
|
||||
): Promise<void> {
|
||||
await this.openSidebarTaskContextMenu(title);
|
||||
await this.page.getByTestId("task-context-send-to-workflow").hover();
|
||||
await this.page.getByTestId(`task-context-workflow-${workflowId}`).hover();
|
||||
await this.page.getByTestId(`task-context-step-${stepId}`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar state indicator — returns the first icon matching the given state label.
|
||||
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test.describe("Cross-workflow selected batch moves", () => {
|
||||
test("right-clicking a selected task sends the selected batch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Batch Target Workflow",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Batch Incoming", 0);
|
||||
const [first, second, unselected] = await Promise.all([
|
||||
apiClient.createTask(seedData.workspaceId, "Batch Move First", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
apiClient.createTask(seedData.workspaceId, "Batch Move Second", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
apiClient.createTask(seedData.workspaceId, "Batch Move Unselected", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
]);
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.selectTask(first.id);
|
||||
await kanban.selectTask(second.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
|
||||
await kanban.sendTaskToWorkflow(first.id, targetWorkflow.id, targetStep.id);
|
||||
|
||||
await expect(kanban.taskCard(first.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(kanban.taskCard(second.id)).not.toBeVisible();
|
||||
await expect(kanban.taskCard(unselected.id)).toBeVisible();
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await expect(kanban.taskCardInColumn("Batch Move First", targetStep.id)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(kanban.taskCardInColumn("Batch Move Second", targetStep.id)).toBeVisible();
|
||||
await expect(kanban.taskCardInColumn("Batch Move Unselected", targetStep.id)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("right-clicking an unselected task moves only that task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Clicked Only Target Workflow",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Clicked Incoming", 0);
|
||||
const [selectedA, selectedB, clicked] = await Promise.all([
|
||||
apiClient.createTask(seedData.workspaceId, "Clicked Only Selected A", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
apiClient.createTask(seedData.workspaceId, "Clicked Only Selected B", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
apiClient.createTask(seedData.workspaceId, "Clicked Only Moved", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
}),
|
||||
]);
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.selectTask(selectedA.id);
|
||||
await kanban.selectTask(selectedB.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
|
||||
await kanban.sendTaskToWorkflow(clicked.id, targetWorkflow.id, targetStep.id);
|
||||
|
||||
await expect(kanban.taskCard(clicked.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(kanban.taskCard(selectedA.id)).toBeVisible();
|
||||
await expect(kanban.taskCard(selectedB.id)).toBeVisible();
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await expect(kanban.taskCardInColumn("Clicked Only Moved", targetStep.id)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
kanban.taskCardInColumn("Clicked Only Selected A", targetStep.id),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
kanban.taskCardInColumn("Clicked Only Selected B", targetStep.id),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test.describe("Cross-workflow task move from home", () => {
|
||||
test("keeps same-workflow Move to as the short path", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
|
||||
if (!targetStep) {
|
||||
test.skip(true, "seed workflow needs at least two steps");
|
||||
return;
|
||||
}
|
||||
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Same Workflow Context Move", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await expect(kanban.contextMoveTo()).toBeVisible();
|
||||
await expect(kanban.contextSendToWorkflow()).not.toBeVisible();
|
||||
await kanban.contextMoveTo().hover();
|
||||
await kanban.contextStep(targetStep.id).click();
|
||||
|
||||
await expect(kanban.taskCardInColumn("Same Workflow Context Move", targetStep.id)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("sends one task to another workflow without changing the current view", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Target Workflow Context Send",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Context Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const beforeUrl = testPage.url();
|
||||
|
||||
await kanban.sendTaskToWorkflow(task.id, targetWorkflow.id, targetStep.id);
|
||||
|
||||
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
expect(testPage.url()).toBe(beforeUrl);
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps right-click and three-dot menus aligned for workflow moves", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const sameWorkflowStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
|
||||
if (!sameWorkflowStep) {
|
||||
test.skip(true, "seed workflow needs at least two steps");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Target Workflow Actions Send",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Actions Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const expectedLabels = ["Edit", "Move to", "Send to workflow", "Archive", "Delete"];
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
for (const label of expectedLabels) {
|
||||
await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible();
|
||||
}
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
for (const label of expectedLabels) {
|
||||
await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible();
|
||||
}
|
||||
await kanban.contextSendToWorkflow().hover();
|
||||
await kanban.contextWorkflow(targetWorkflow.id).hover();
|
||||
await kanban.contextStep(targetStep.id).click();
|
||||
|
||||
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.taskCardInColumn("Cross Workflow Actions Task", targetStep.id)).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("shows no-step workflows as disabled send targets", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const emptyWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Empty Workflow Context Send",
|
||||
);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "No Step Target Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await kanban.contextSendToWorkflow().hover();
|
||||
|
||||
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeVisible();
|
||||
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeDisabled();
|
||||
await expect(
|
||||
kanban.contextWorkflow(emptyWorkflow.id).getByTestId("task-context-disabled-reason"),
|
||||
).toContainText("No steps");
|
||||
});
|
||||
|
||||
test("marks auto-start target steps before moving", async ({ testPage, apiClient, seedData }) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Auto Start Context Target",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Run agent", 0);
|
||||
await apiClient.updateWorkflowStep(targetStep.id, {
|
||||
prompt: 'e2e:message("context move auto-started")',
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Auto Start Marker Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await kanban.contextSendToWorkflow().hover();
|
||||
await kanban.contextWorkflow(targetWorkflow.id).hover();
|
||||
await expect(kanban.contextAutoStartStep(targetStep.id)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("Task sidebar send to workflow", () => {
|
||||
test("right-click sends a sidebar task to another workflow without navigation", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Sidebar Target Workflow",
|
||||
);
|
||||
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Sidebar Incoming", 0);
|
||||
const anchor = await apiClient.createTask(seedData.workspaceId, "Sidebar Anchor Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
await apiClient.createTask(seedData.workspaceId, "Sidebar Send Candidate", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${anchor.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.sidebarTaskItem("Sidebar Send Candidate")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const beforeUrl = testPage.url();
|
||||
|
||||
await session.sendSidebarTaskToWorkflow(
|
||||
"Sidebar Send Candidate",
|
||||
targetWorkflow.id,
|
||||
targetStep.id,
|
||||
);
|
||||
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
expect(testPage.url()).toBe(beforeUrl);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await testPage.reload();
|
||||
await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { bulkMoveSelectedTasks } from "@/lib/api";
|
||||
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
bulkMoveSelectedTasks: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBulkMoveSelectedTasks = vi.mocked(bulkMoveSelectedTasks);
|
||||
const TASK_1 = "task-1";
|
||||
const TASK_2 = "task-2";
|
||||
const TARGET_WORKFLOW_ID = "wf-2";
|
||||
const TARGET_STEP_ID = "step-2";
|
||||
|
||||
describe("useTaskWorkflowMove", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("dedupes and filters task ids before moving", async () => {
|
||||
mockBulkMoveSelectedTasks.mockResolvedValue({ moved_count: 2 });
|
||||
const { result } = renderHook(() => useTaskWorkflowMove());
|
||||
|
||||
await act(async () => {
|
||||
await result.current([TASK_1, "", TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
|
||||
});
|
||||
|
||||
expect(mockBulkMoveSelectedTasks).toHaveBeenCalledWith({
|
||||
task_ids: [TASK_1, TASK_2],
|
||||
target_workflow_id: TARGET_WORKFLOW_ID,
|
||||
target_step_id: TARGET_STEP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing for an empty task id list", async () => {
|
||||
const { result } = renderHook(() => useTaskWorkflowMove());
|
||||
|
||||
await act(async () => {
|
||||
await result.current([""], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
|
||||
});
|
||||
|
||||
expect(mockBulkMoveSelectedTasks).not.toHaveBeenCalled();
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows singular, plural, and zero-count success messages", async () => {
|
||||
mockBulkMoveSelectedTasks
|
||||
.mockResolvedValueOnce({ moved_count: 1 })
|
||||
.mockResolvedValueOnce({ moved_count: 2 })
|
||||
.mockResolvedValueOnce({ moved_count: 0 });
|
||||
const { result } = renderHook(() => useTaskWorkflowMove());
|
||||
|
||||
await act(async () => {
|
||||
await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
|
||||
await result.current([TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
|
||||
await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenNthCalledWith(1, {
|
||||
title: "Moved task to workflow",
|
||||
description: "Switch to the destination workflow to see it.",
|
||||
variant: "success",
|
||||
});
|
||||
expect(mockToast).toHaveBeenNthCalledWith(2, {
|
||||
title: "Moved 2 tasks to workflow",
|
||||
description: "Switch to the destination workflow to see them.",
|
||||
variant: "success",
|
||||
});
|
||||
expect(mockToast).toHaveBeenNthCalledWith(3, {
|
||||
title: "Moved 0 tasks to workflow",
|
||||
description: "Switch to the destination workflow to see them.",
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error toast and rethrows move failures", async () => {
|
||||
const error = new Error("cannot move running task");
|
||||
mockBulkMoveSelectedTasks.mockRejectedValue(error);
|
||||
const { result } = renderHook(() => useTaskWorkflowMove());
|
||||
|
||||
await expect(result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID)).rejects.toThrow(
|
||||
error,
|
||||
);
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Failed to move task",
|
||||
description: "cannot move running task",
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { bulkMoveSelectedTasks } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "Failed to move task";
|
||||
}
|
||||
|
||||
function movedTitle(movedCount: number) {
|
||||
if (movedCount === 1) return "Moved task to workflow";
|
||||
return `Moved ${movedCount} tasks to workflow`;
|
||||
}
|
||||
|
||||
function movedDescription(movedCount: number) {
|
||||
return movedCount === 1
|
||||
? "Switch to the destination workflow to see it."
|
||||
: "Switch to the destination workflow to see them.";
|
||||
}
|
||||
|
||||
export function useTaskWorkflowMove() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(
|
||||
async (taskIds: string[], targetWorkflowId: string, targetStepId: string) => {
|
||||
const ids = [...new Set(taskIds.filter(Boolean))];
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
const result = await bulkMoveSelectedTasks({
|
||||
task_ids: ids,
|
||||
target_workflow_id: targetWorkflowId,
|
||||
target_step_id: targetStepId,
|
||||
});
|
||||
toast({
|
||||
title: movedTitle(result.moved_count),
|
||||
description: movedDescription(result.moved_count),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to move task",
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,16 @@ export async function moveTask(
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkMoveSelectedTasks(
|
||||
payload: { task_ids: string[]; target_workflow_id: string; target_step_id: string },
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ moved_count: number }>("/api/v1/tasks/bulk-move", {
|
||||
...options,
|
||||
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ export type KanbanUpdatePayload = {
|
||||
export type TaskEventPayload = {
|
||||
task_id: string;
|
||||
workflow_id: string;
|
||||
old_workflow_id?: string | null;
|
||||
workflow_step_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
|
||||
@@ -195,6 +195,38 @@ describe("task.updated primary-session focus follow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task.updated cross-workflow placement", () => {
|
||||
it("removes the task from its old workflow snapshot before upserting into the new one", () => {
|
||||
const task = { id: "t1", title: "Test", workflowId: "wf1", workflowStepId: "step1" };
|
||||
const store = makeStore({
|
||||
kanban: {
|
||||
workflowId: "wf1",
|
||||
steps: [],
|
||||
tasks: [task],
|
||||
} as unknown as AppState["kanban"],
|
||||
kanbanMulti: {
|
||||
isLoading: false,
|
||||
snapshots: {
|
||||
wf1: { workflow: { id: "wf1" }, steps: [], tasks: [task] },
|
||||
wf2: { workflow: { id: "wf2" }, steps: [], tasks: [] },
|
||||
},
|
||||
} as unknown as AppState["kanbanMulti"],
|
||||
});
|
||||
|
||||
const handlers = registerTasksHandlers(store);
|
||||
handlers["task.updated"]!(
|
||||
makeMessage({ ...makeTask("t1", null, "wf2"), old_workflow_id: "wf1" }),
|
||||
);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.kanban.tasks).toHaveLength(0);
|
||||
expect(state.kanbanMulti.snapshots.wf1.tasks).toHaveLength(0);
|
||||
expect(state.kanbanMulti.snapshots.wf2.tasks).toHaveLength(1);
|
||||
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.id).toBe("t1");
|
||||
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.workflowStepId).toBe("step1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("task.deleted cleanup", () => {
|
||||
it("removes the deleted task from recent task history", () => {
|
||||
const store = makeStore({
|
||||
|
||||
@@ -36,6 +36,7 @@ function upsertMultiTask(state: AppState, workflowId: string, task: KanbanTask):
|
||||
|
||||
type TaskEventPayload = TaskLike & {
|
||||
workflow_id: string;
|
||||
old_workflow_id?: string | null;
|
||||
is_ephemeral?: boolean;
|
||||
archived_at?: string | null;
|
||||
};
|
||||
@@ -123,12 +124,18 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
|
||||
store.setState((state) => {
|
||||
const wfId = message.payload.workflow_id;
|
||||
const oldWfId = message.payload.old_workflow_id;
|
||||
let next = state;
|
||||
|
||||
if (message.payload.archived_at) {
|
||||
return removeTaskFromBothKanbans(state, wfId, taskId);
|
||||
if (oldWfId && oldWfId !== wfId) {
|
||||
next = removeTaskFromBothKanbans(next, oldWfId, taskId);
|
||||
}
|
||||
|
||||
return upsertTaskInBothKanbans(state, wfId, message.payload);
|
||||
if (message.payload.archived_at) {
|
||||
return removeTaskFromBothKanbans(next, wfId, taskId);
|
||||
}
|
||||
|
||||
return upsertTaskInBothKanbans(next, wfId, message.payload);
|
||||
});
|
||||
|
||||
// Follow focus to the new primary when:
|
||||
|
||||
Reference in New Issue
Block a user