fix: ux consistency on archiving and deleting tasks (#627)

This commit is contained in:
Francisco Magalhães
2026-04-17 23:20:10 +01:00
committed by GitHub
parent 73e58729ba
commit cd46516cef
12 changed files with 256 additions and 143 deletions
+20 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Row, ColumnDef } from "@tanstack/react-table";
import type { Task, Workflow, WorkflowStep, Repository } from "@/lib/types/http";
import Link from "next/link";
@@ -8,6 +9,8 @@ import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { formatDistanceToNow } from "date-fns";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
type TaskWithResolution = Task & {
workflowName?: string;
@@ -66,6 +69,8 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
const task = row.original;
const isDeleting = ctx.deletingTaskId === task.id;
const isArchived = !!task.archived_at;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
return (
<div className="flex items-center justify-end gap-0.5">
{!isArchived && (
@@ -77,7 +82,7 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
className="cursor-pointer h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
ctx.onArchive(task.id);
setShowArchiveConfirm(true);
}}
>
<IconArchive className="h-3.5 w-3.5 text-muted-foreground" />
@@ -95,7 +100,7 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation();
ctx.onDelete(task.id);
setShowDeleteConfirm(true);
}}
>
{isDeleting ? (
@@ -107,6 +112,19 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
isDeleting={isDeleting}
onConfirm={() => ctx.onDelete(task.id)}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
onConfirm={() => ctx.onArchive(task.id)}
/>
</div>
);
}
-1
View File
@@ -108,7 +108,6 @@ function useTaskOperations({
setDeletingTaskId(taskId);
try {
await deleteTask(taskId);
toast({ title: "Task deleted", description: "The task has been deleted successfully." });
fetchTasks();
} catch (err) {
toast({
+4 -55
View File
@@ -13,16 +13,7 @@ import {
import { Checkbox } from "@kandev/ui/checkbox";
import { Card, CardContent } from "@kandev/ui/card";
import { Badge } from "@kandev/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -41,7 +32,7 @@ import { needsAction } from "@/lib/utils/needs-action";
import { useAppStore } from "@/components/state-provider";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { ArchiveConfirmDialog } from "@/components/task/archive-confirm-dialog";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
export interface Task {
id: string;
@@ -258,48 +249,6 @@ function KanbanCardActions({
);
}
function DeleteConfirmDialog({
open,
onOpenChange,
taskTitle,
isDeleting,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
taskTitle: string;
isDeleting?: boolean;
onConfirm: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete task</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{taskTitle}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (isDeleting) return;
onConfirm();
onOpenChange(false);
}}
>
{isDeleting ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function MoveToSubmenu({
task,
steps,
@@ -420,14 +369,14 @@ function KanbanCardMenu(props: KanbanCardMenuProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmDialog
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
isDeleting={isDeleting}
onConfirm={() => onDelete?.(task)}
/>
<ArchiveConfirmDialog
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { IconArchive, IconDots, IconTrash } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,6 +9,8 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
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 { Graph2StepNode } from "./graph2-step-node";
import { Graph2Connector } from "./graph2-connector";
@@ -40,8 +42,10 @@ export type Graph2TaskPipelineProps = {
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onArchiveTask?: (task: Task) => void;
isMoving?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
};
function getStepPhase(index: number, currentStepIndex: number): "past" | "current" | "future" {
@@ -97,6 +101,7 @@ function PipelineStepNodes({
onPreviewTask={onPreviewTask}
isMoving={isMoving}
/>
{connectorType && <Graph2Connector type={connectorType} />}
</div>
);
@@ -105,6 +110,69 @@ function PipelineStepNodes({
);
}
function TaskActions({
task,
onDeleteTask,
onArchiveTask,
isDeleting,
isArchiving,
}: Pick<
Graph2TaskPipelineProps,
"task" | "onDeleteTask" | "onArchiveTask" | "isDeleting" | "isArchiving"
>) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<IconDots className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
{onArchiveTask && (
<DropdownMenuItem
onClick={() => setShowArchiveConfirm(true)}
disabled={isArchiving}
className="cursor-pointer"
>
<IconArchive className="h-3.5 w-3.5 mr-2" />
Archive task
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="text-destructive focus:text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5 mr-2" />
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
isDeleting={isDeleting}
onConfirm={() => onDeleteTask(task)}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
isArchiving={isArchiving}
onConfirm={() => onArchiveTask?.(task)}
/>
</>
);
}
export function Graph2TaskPipeline({
task,
steps,
@@ -112,14 +180,15 @@ export function Graph2TaskPipeline({
onPreviewTask,
onOpenTask,
onDeleteTask,
onArchiveTask,
isMoving,
isDeleting,
isArchiving,
}: Graph2TaskPipelineProps) {
const currentStepIndex = useMemo(
() => steps.findIndex((s) => s.id === task.workflowStepId),
[steps, task.workflowStepId],
);
const hasAction = needsAction(task);
const sessionCount = task.sessionCount ?? 0;
@@ -152,7 +221,6 @@ export function Graph2TaskPipeline({
)}
</div>
</button>
<PipelineStepNodes
steps={steps}
currentStepIndex={currentStepIndex}
@@ -161,27 +229,13 @@ export function Graph2TaskPipeline({
onPreviewTask={onPreviewTask}
isMoving={isMoving}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<IconDots className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => onDeleteTask(task)}
disabled={isDeleting}
className="text-destructive focus:text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5 mr-2" />
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TaskActions
task={task}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
</div>
</div>
);
@@ -12,8 +12,10 @@ export function SwimlaneGraph2Content({
onPreviewTask,
onOpenTask,
onDeleteTask,
onArchiveTask,
onMoveError,
deletingTaskId,
archivingTaskId,
}: ViewContentProps) {
const { moveTask } = useSwimlaneMove(workflowId, {
onMoveError,
@@ -60,8 +62,10 @@ export function SwimlaneGraph2Content({
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
isMoving={movingTaskId === task.id}
isDeleting={deletingTaskId === task.id}
isArchiving={archivingTaskId === task.id}
/>
))}
</div>
@@ -1,18 +1,10 @@
"use client";
import { useState } from "react";
import { IconLoader, IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react";
import { IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,6 +25,43 @@ interface TaskMultiSelectToolbarProps {
onBulkMove: (targetStepId: string) => Promise<void>;
}
function BulkArchiveDialog({
count,
isProcessing,
onConfirm,
}: {
count: number;
isProcessing: boolean;
onConfirm: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button
size="sm"
variant="outline"
className="cursor-pointer gap-1.5"
disabled={isProcessing}
onClick={() => setOpen(true)}
data-testid="bulk-archive-button"
>
<IconArchive className="h-4 w-4" />
Archive {count}
</Button>
<TaskArchiveConfirmDialog
open={open}
onOpenChange={setOpen}
isBulkOperation
count={count}
isArchiving={isProcessing}
onConfirm={onConfirm}
confirmTestId="bulk-archive-confirm"
/>
</>
);
}
function BulkDeleteDialog({
count,
isProcessing,
@@ -57,32 +86,15 @@ function BulkDeleteDialog({
<IconTrash className="h-4 w-4" />
Delete {count}
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {count} tasks</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {count} task{count !== 1 ? "s" : ""}? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isProcessing}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onConfirm();
setOpen(false);
}}
data-testid="bulk-delete-confirm"
>
{isProcessing ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TaskDeleteConfirmDialog
open={open}
onOpenChange={setOpen}
isBulkOperation
count={count}
isDeleting={isProcessing}
onConfirm={onConfirm}
confirmTestId="bulk-delete-confirm"
/>
</>
);
}
@@ -143,17 +155,11 @@ export function TaskMultiSelectToolbar({
</DropdownMenu>
)}
<Button
size="sm"
variant="outline"
className="cursor-pointer gap-1.5"
disabled={isProcessing}
onClick={() => onBulkArchive()}
data-testid="bulk-archive-button"
>
<IconArchive className="h-4 w-4" />
Archive {count}
</Button>
<BulkArchiveDialog
count={count}
isProcessing={isProcessing}
onConfirm={() => onBulkArchive()}
/>
<BulkDeleteDialog
count={count}
@@ -7,7 +7,7 @@ import { Button } from "@kandev/ui/button";
import { TaskSwitcher } from "../task-switcher";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { ArchiveConfirmDialog } from "../archive-confirm-dialog";
import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { replaceTaskUrl } from "@/lib/links";
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
@@ -467,7 +467,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
onSuccess={handleTaskCreated}
/>
<ArchiveConfirmDialog
<TaskArchiveConfirmDialog
open={archivingTask !== null}
onOpenChange={(open) => {
if (!open) setArchivingTask(null);
@@ -12,29 +12,42 @@ import {
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
type ArchiveConfirmDialogProps = {
type TaskArchiveConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
taskTitle: string;
taskTitle?: string;
isBulkOperation?: boolean;
count?: number;
isArchiving?: boolean;
onConfirm: () => void;
confirmTestId?: string;
};
export function ArchiveConfirmDialog({
export function TaskArchiveConfirmDialog({
open,
onOpenChange,
taskTitle,
isBulkOperation,
count,
isArchiving,
onConfirm,
}: ArchiveConfirmDialogProps) {
confirmTestId,
}: TaskArchiveConfirmDialogProps) {
const safeCount = count ?? 0;
const label = isBulkOperation ? `task${safeCount !== 1 ? "s" : ""}` : "task";
const title = isBulkOperation ? `Archive ${safeCount} ${label}?` : "Archive task?";
const firstLine = isBulkOperation
? `Are you sure you want to archive ${safeCount} ${label}?`
: `Are you sure you want to archive "${taskTitle}"?`;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Archive task?</AlertDialogTitle>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>Are you sure you want to archive &quot;{taskTitle}&quot;?</p>
<p>{firstLine}</p>
<p className="mt-2">
This will delete the task&apos;s worktree and stop any running agent sessions.
</p>
@@ -46,10 +59,11 @@ export function ArchiveConfirmDialog({
<AlertDialogAction
disabled={isArchiving}
className="cursor-pointer"
onClick={(e) => {
e.preventDefault();
data-testid={confirmTestId}
onClick={() => {
if (isArchiving) return;
onConfirm();
onOpenChange(false);
}}
>
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
@@ -0,0 +1,65 @@
import { IconLoader } from "@tabler/icons-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
export function TaskDeleteConfirmDialog({
open,
onOpenChange,
taskTitle,
isBulkOperation,
count,
isDeleting,
onConfirm,
confirmTestId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
taskTitle?: string;
isBulkOperation?: boolean;
count?: number;
isDeleting?: boolean;
onConfirm: () => void;
confirmTestId?: string;
}) {
const safeCount = count ?? 0;
const label = isBulkOperation ? `task${safeCount !== 1 ? "s" : ""}` : "task";
const title = isBulkOperation ? `Delete ${safeCount} ${label}` : "Delete task";
const description = isBulkOperation
? `Are you sure you want to delete ${safeCount} ${label}? This action cannot be undone.`
: `Are you sure you want to delete "${taskTitle}"? This action cannot be undone.`;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
data-testid={confirmTestId}
onClick={() => {
if (isDeleting) return;
onConfirm();
onOpenChange(false);
}}
>
{isDeleting ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -8,7 +8,7 @@ import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import { TaskSwitcher } from "./task-switcher";
import type { TaskSwitcherItem } from "./task-switcher";
import { TaskRenameDialog } from "./task-rename-dialog";
import { ArchiveConfirmDialog } from "./archive-confirm-dialog";
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
import { Button } from "@kandev/ui/button";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { IconPlus } from "@tabler/icons-react";
@@ -662,7 +662,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
currentTitle={renamingTask?.title ?? ""}
onSubmit={handleRenameSubmit}
/>
<ArchiveConfirmDialog
<TaskArchiveConfirmDialog
open={archivingTask !== null}
onOpenChange={(open) => {
if (!open) setArchivingTask(null);
+2
View File
@@ -9,6 +9,7 @@ export class KanbanPage {
readonly bulkMoveButton: Locator;
readonly bulkClearButton: Locator;
readonly bulkDeleteConfirm: Locator;
readonly bulkArchiveConfirm: Locator;
readonly multiSelectToggle: Locator;
@@ -22,6 +23,7 @@ export class KanbanPage {
this.bulkMoveButton = page.getByTestId("bulk-move-button");
this.bulkClearButton = page.getByTestId("bulk-clear-selection");
this.bulkDeleteConfirm = page.getByTestId("bulk-delete-confirm");
this.bulkArchiveConfirm = page.getByTestId("bulk-archive-confirm");
}
async goto() {
@@ -111,6 +111,7 @@ test.describe("Multi-select bulk actions", () => {
await kanban.selectTask(t2.id);
await kanban.bulkArchiveButton.click();
await kanban.bulkArchiveConfirm.click();
await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 });
await expect(kanban.taskCard(t2.id)).not.toBeVisible();
@@ -142,6 +143,7 @@ test.describe("Multi-select bulk actions", () => {
await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible();
await kanban.bulkArchiveButton.click();
await kanban.bulkArchiveConfirm.click();
await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 });
await expect(kanban.taskCard(t2.id)).not.toBeVisible();