feat(web): show repo name and enable multi-select in pipeline view (#831)

* feat(web): show repo name and enable multi-select in pipeline view

* test(web): add e2e tests for pipeline view repo name and multi-select

* fix(web): tighten pipeline-view click handling and pipeline-view e2e wait sentinel

- Toggle selection on task-button click whenever isSelected is truthy, so
  a lingering selectedIds entry (after multi-select is turned off) cannot
  leave a visible checkbox with a broken click target.
- switchToPipelineView() now waits for [data-testid^=\pipeline-task-\]
  rather than swimlane-container, which is shared with the kanban view
  and could resolve before the pipeline view renders.

Addresses Greptile review on PR #831.
This commit is contained in:
Filipe Tomás
2026-05-10 18:03:43 +01:00
committed by GitHub
parent 1066ac5b16
commit 66be6141fd
5 changed files with 261 additions and 35 deletions
@@ -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 (
<button
type="button"
onClick={onClick}
className={cn(
"w-[200px] shrink-0 rounded-md px-2.5 py-1.5 text-left transition-colors cursor-pointer",
"hover:bg-accent/60 active:bg-accent/80",
"border border-transparent hover:border-border/50",
hasAction && !isSelected && "border-l-2 !border-l-amber-500",
isSelected && "ring-1 ring-primary/60 border-primary/60",
)}
>
<span className="text-xs font-medium truncate block text-foreground/80">{task.title}</span>
{repoName && (
<span
data-testid={`pipeline-task-repo-${task.id}`}
className="text-xs text-muted-foreground/60 truncate block"
>
{repoName}
</span>
)}
<div className="flex items-center gap-1.5 mt-0.5">
{task.updatedAt && (
<span className="text-[10px] text-muted-foreground/60">
{formatRelativeTime(task.updatedAt)}
</span>
)}
{sessionCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
{sessionCount} {sessionCount === 1 ? "session" : "sessions"}
</span>
)}
</div>
</button>
);
}
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 (
<div className="flex items-center justify-center rounded-lg hover:bg-muted/30 transition-colors px-3 py-2">
<div
data-testid={`pipeline-task-${task.id}`}
className="flex items-center justify-center rounded-lg hover:bg-muted/30 transition-colors px-3 py-2"
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onOpenTask(task)}
className={cn(
"w-[160px] shrink-0 rounded-md px-2.5 py-1.5 text-left transition-colors cursor-pointer",
"hover:bg-accent/60 active:bg-accent/80",
"border border-transparent hover:border-border/50",
hasAction && "border-l-2 !border-l-amber-500",
)}
>
<span className="text-xs font-medium truncate block text-foreground/80">
{task.title}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
{task.updatedAt && (
<span className="text-[10px] text-muted-foreground/60">
{formatRelativeTime(task.updatedAt)}
</span>
)}
{sessionCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
{sessionCount} {sessionCount === 1 ? "session" : "sessions"}
</span>
)}
{showCheckbox && (
<div
className="shrink-0"
onClick={handleCheckboxClick}
data-testid={`task-select-checkbox-${task.id}`}
>
<Checkbox
checked={!!isSelected}
aria-label={`Select task ${task.title}`}
className="cursor-pointer border-muted-foreground/50"
/>
</div>
</button>
)}
<TaskButton
task={task}
repoName={repoName}
isSelected={isSelected}
onClick={handleTaskClick}
/>
<PipelineStepNodes
steps={steps}
currentStepIndex={currentStepIndex}
@@ -229,13 +309,15 @@ export function Graph2TaskPipeline({
onPreviewTask={onPreviewTask}
isMoving={isMoving}
/>
<TaskActions
task={task}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
{!isMultiSelectMode && (
<TaskActions
task={task}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
)}
</div>
</div>
);
@@ -181,6 +181,7 @@ function ViewToggleGroup({
<ToggleGroupItem
key={value}
value={value}
data-testid={`view-toggle-${value}`}
className={`cursor-pointer data-[state=on]:bg-muted data-[state=on]:text-foreground ${itemClassName ?? ""}`}
>
<Tooltip>
@@ -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}
/>
))}
</div>
+26
View File
@@ -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();
}
}
@@ -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();
});
});