diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx index 7c0e31ab0b..d9be6ed4b6 100644 --- a/apps/web/components/kanban/graph2-task-pipeline.tsx +++ b/apps/web/components/kanban/graph2-task-pipeline.tsx @@ -8,10 +8,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; +import { Checkbox } from "@kandev/ui/checkbox"; import { cn } from "@kandev/ui/lib/utils"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { needsAction } from "@/lib/utils/needs-action"; +import { useAppStore } from "@/components/state-provider"; import { Graph2StepNode } from "./graph2-step-node"; import { Graph2Connector } from "./graph2-connector"; import type { Task } from "@/components/kanban-card"; @@ -46,6 +48,9 @@ export type Graph2TaskPipelineProps = { isMoving?: boolean; isDeleting?: boolean; isArchiving?: boolean; + isSelected?: boolean; + onToggleSelect?: (taskId: string) => void; + isMultiSelectMode?: boolean; }; function getStepPhase(index: number, currentStepIndex: number): "past" | "current" | "future" { @@ -173,6 +178,69 @@ function TaskActions({ ); } +function TaskButton({ + task, + repoName, + isSelected, + onClick, +}: { + task: Task; + repoName: string | undefined; + isSelected?: boolean; + onClick: () => void; +}) { + const hasAction = needsAction(task); + const sessionCount = task.sessionCount ?? 0; + return ( + + ); +} + +function useTaskRepoName(task: Task): string | undefined { + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + return useMemo(() => { + const primaryRepoId = task.repositories?.[0]?.repository_id; + if (!primaryRepoId) return undefined; + for (const repos of Object.values(repositoriesByWorkspace)) { + const repo = repos.find((r) => r.id === primaryRepoId); + if (repo) return repo.name; + } + return undefined; + }, [repositoriesByWorkspace, task.repositories]); +} + export function Graph2TaskPipeline({ task, steps, @@ -184,43 +252,55 @@ export function Graph2TaskPipeline({ isMoving, isDeleting, isArchiving, + isSelected, + onToggleSelect, + isMultiSelectMode, }: Graph2TaskPipelineProps) { const currentStepIndex = useMemo( () => steps.findIndex((s) => s.id === task.workflowStepId), [steps, task.workflowStepId], ); - const hasAction = needsAction(task); - const sessionCount = task.sessionCount ?? 0; + const repoName = useTaskRepoName(task); + const showCheckbox = isMultiSelectMode || !!isSelected; + + const handleTaskClick = () => { + if (isMultiSelectMode || isSelected) { + onToggleSelect?.(task.id); + return; + } + onOpenTask(task); + }; + + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleSelect?.(task.id); + }; return ( -
+
- + )} + - + {!isMultiSelectMode && ( + + )}
); diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 08302ecfbd..eff8fab1c9 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -181,6 +181,7 @@ function ViewToggleGroup({ diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx index 4a669a5199..8514526907 100644 --- a/apps/web/components/kanban/swimlane-graph2-content.tsx +++ b/apps/web/components/kanban/swimlane-graph2-content.tsx @@ -16,6 +16,9 @@ export function SwimlaneGraph2Content({ onMoveError, deletingTaskId, archivingTaskId, + selectedIds, + onToggleSelect, + isMultiSelectMode, }: ViewContentProps) { const { moveTask } = useSwimlaneMove(workflowId, { onMoveError, @@ -66,6 +69,9 @@ export function SwimlaneGraph2Content({ isMoving={movingTaskId === task.id} isDeleting={deletingTaskId === task.id} isArchiving={archivingTaskId === task.id} + isSelected={selectedIds?.has(task.id)} + onToggleSelect={onToggleSelect} + isMultiSelectMode={isMultiSelectMode} /> ))}
diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts index 58e702804d..c79ac63872 100644 --- a/apps/web/e2e/pages/kanban-page.ts +++ b/apps/web/e2e/pages/kanban-page.ts @@ -13,6 +13,9 @@ export class KanbanPage { readonly multiSelectToggle: Locator; + readonly viewTogglePipeline: Locator; + readonly viewToggleKanban: Locator; + constructor(private page: Page) { this.board = page.getByTestId("kanban-board"); this.createTaskButton = page.getByTestId("create-task-button"); @@ -24,6 +27,8 @@ export class KanbanPage { this.bulkClearButton = page.getByTestId("bulk-clear-selection"); this.bulkDeleteConfirm = page.getByTestId("bulk-delete-confirm"); this.bulkArchiveConfirm = page.getByTestId("bulk-archive-confirm"); + this.viewTogglePipeline = page.getByTestId("view-toggle-pipeline"); + this.viewToggleKanban = page.getByTestId("view-toggle-kanban"); } async goto() { @@ -128,4 +133,25 @@ export class KanbanPage { await this.enableMultiSelect(); await this.taskSelectCheckbox(taskId).click(); } + + pipelineTask(taskId: string): Locator { + return this.page.getByTestId(`pipeline-task-${taskId}`); + } + + pipelineTaskRepoName(taskId: string): Locator { + return this.page.getByTestId(`pipeline-task-repo-${taskId}`); + } + + async switchToPipelineView() { + await this.viewTogglePipeline.first().click(); + // Wait for a pipeline-specific element — swimlane-container is shared with the kanban view. + await this.page.locator('[data-testid^="pipeline-task-"]').first().waitFor(); + } + + async selectPipelineTask(taskId: string) { + const row = this.pipelineTask(taskId); + await row.waitFor({ state: "visible" }); + await this.enableMultiSelect(); + await this.taskSelectCheckbox(taskId).click(); + } } diff --git a/apps/web/e2e/tests/kanban/pipeline-view.spec.ts b/apps/web/e2e/tests/kanban/pipeline-view.spec.ts new file mode 100644 index 0000000000..b467595d9e --- /dev/null +++ b/apps/web/e2e/tests/kanban/pipeline-view.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Pipeline view", () => { + test.beforeEach(async ({ testPage }) => { + // The view toggle and multi-select toggle are only rendered on desktop layouts. + await testPage.setViewportSize({ width: 1280, height: 800 }); + }); + + test("shows the linked repository name beneath the task title", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Pipeline Repo Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.switchToPipelineView(); + + const repoName = kanban.pipelineTaskRepoName(task.id); + await expect(repoName).toBeVisible(); + // seedData seeds the repository with the default name from createRepository(). + await expect(repoName).toHaveText("E2E Repo"); + }); + + test("does not show a repo name when the task has no linked repository", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Pipeline No Repo Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.switchToPipelineView(); + + await expect(kanban.pipelineTask(task.id)).toBeVisible(); + await expect(kanban.pipelineTaskRepoName(task.id)).toHaveCount(0); + }); + + test("multi-select checkbox appears and toolbar reflects the selection", async ({ + testPage, + apiClient, + seedData, + }) => { + const [t1, t2] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "Pipeline MS 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Pipeline MS 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.switchToPipelineView(); + + // Checkbox is hidden until multi-select mode is enabled. + await expect(kanban.taskSelectCheckbox(t1.id)).toHaveCount(0); + await expect(kanban.multiSelectToolbar).not.toBeVisible(); + + await kanban.selectPipelineTask(t1.id); + await expect(kanban.taskSelectCheckbox(t1.id)).toBeVisible(); + await expect(kanban.multiSelectToolbar).toBeVisible(); + await expect(kanban.multiSelectToolbar).toContainText("1 selected"); + + // Once multi-select mode is on, every pipeline task exposes a checkbox. + await kanban.taskSelectCheckbox(t2.id).click(); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + }); + + test("bulk delete from pipeline view removes the selected tasks", async ({ + testPage, + apiClient, + seedData, + }) => { + const [t1, t2] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "Pipeline Delete 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Pipeline Delete 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.switchToPipelineView(); + + await kanban.selectPipelineTask(t1.id); + await kanban.taskSelectCheckbox(t2.id).click(); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + + await kanban.bulkDeleteButton.click(); + await expect(kanban.bulkDeleteConfirm).toBeVisible(); + await kanban.bulkDeleteConfirm.click(); + + await expect(kanban.pipelineTask(t1.id)).toHaveCount(0, { timeout: 10000 }); + await expect(kanban.pipelineTask(t2.id)).toHaveCount(0); + await expect(kanban.multiSelectToolbar).not.toBeVisible(); + }); +});