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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user