fix: refine task confirmation dialogs (#2553)

This commit is contained in:
Carlos Florêncio
2026-08-12 14:53:09 +01:00
committed by GitHub
parent 1f1710e54f
commit ce4ea447f3
6 changed files with 105 additions and 18 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ function AlertDialogContent({
onKeyDown,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
size?: "default" | "sm" | "lg";
/** Pressing Enter activates the semantic action button. Default: true. */
enterConfirms?: boolean;
overlayClassName?: string;
@@ -72,7 +72,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-3 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-3 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm data-[size=lg]:w-[calc(100vw-2rem)] data-[size=lg]:!max-w-[calc(100vw-2rem)] data-[size=lg]:sm:!max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className,
)}
onKeyDown={handleKeyDown}
@@ -18,6 +18,7 @@ import { useSubtaskCount } from "@/hooks/use-subtask-count";
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
import { StillWorkingWarning } from "./task-still-working-warning";
import { TASK_CONFIRM_CLASS, stopDialogPropagation } from "./task-confirm-dialog-shared";
import { useTranslation } from "react-i18next";
type TaskArchiveConfirmDialogProps = {
@@ -129,10 +130,10 @@ export function TaskArchiveConfirmDialog({
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogContent size="lg" className={TASK_CONFIRM_CLASS} onClick={stopDialogPropagation}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<AlertDialogTitle className="text-base font-semibold">{title}</AlertDialogTitle>
<AlertDialogDescription asChild className="text-sm leading-6">
<div>
<p>{firstLine}</p>
{cleanup.lines.map((line, i) => (
@@ -154,17 +155,19 @@ export function TaskArchiveConfirmDialog({
/>
<span>
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
<span className="block text-xs text-muted-foreground">
<span className="block text-sm text-muted-foreground">
{t("task:subtasksStayActiveUnlessYouTick")}
</span>
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer !text-sm">
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isArchiving}
className="cursor-pointer"
className="cursor-pointer !text-sm"
data-testid={confirmTestId}
onClick={() => {
if (isArchiving) return;
@@ -0,0 +1,4 @@
import type { MouseEvent } from "react";
export const TASK_CONFIRM_CLASS = "font-sans !text-sm";
export const stopDialogPropagation = (event: MouseEvent<HTMLDivElement>) => event.stopPropagation();
@@ -17,6 +17,7 @@ import { useSubtaskCount } from "@/hooks/use-subtask-count";
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
import { StillWorkingWarning } from "./task-still-working-warning";
import { TASK_CONFIRM_CLASS, stopDialogPropagation } from "./task-confirm-dialog-shared";
import { useTranslation } from "react-i18next";
type TaskDeleteConfirmDialogProps = {
@@ -75,10 +76,10 @@ export function TaskDeleteConfirmDialog({
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogContent size="lg" className={TASK_CONFIRM_CLASS} onClick={stopDialogPropagation}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<AlertDialogTitle className="text-base font-semibold">{title}</AlertDialogTitle>
<AlertDialogDescription asChild className="text-sm leading-6">
<div>
<p>{description}</p>
{cleanup.lines.map((line, i) => (
@@ -102,17 +103,19 @@ export function TaskDeleteConfirmDialog({
/>
<span>
{t("task:alsoDeleteSubtasks", { count: subtaskCount })}
<span className="block text-xs text-muted-foreground">
<span className="block text-sm text-muted-foreground">
{t("task:subtasksBecomeRootTasksUnlessYou")}
</span>
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer !text-sm">
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="cursor-pointer bg-destructive !text-sm text-destructive-foreground hover:bg-destructive/90"
data-testid={confirmTestId}
onClick={() => {
if (isDeleting) return;
@@ -26,8 +26,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", (
await deleteItem.click();
// Confirm dialog must appear with the task title
await expect(testPage.getByRole("alertdialog")).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Delete Task");
const dialog = testPage.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("Card Menu Delete Task");
const dialogBox = await dialog.boundingBox();
if (!dialogBox) throw new Error("delete confirmation dialog has no layout box");
expect(dialogBox.width).toBeGreaterThanOrEqual(480);
await expect(dialog).toHaveClass(/font-sans/);
// URL must not have changed (no navigation to /tasks/:id and no ?taskId=)
expect(testPage.url()).toBe(startUrl);
@@ -53,8 +58,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", (
await expect(archiveItem).toBeVisible();
await archiveItem.click();
await expect(testPage.getByRole("alertdialog")).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Archive Task");
const dialog = testPage.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("Card Menu Archive Task");
const dialogBox = await dialog.boundingBox();
if (!dialogBox) throw new Error("archive confirmation dialog has no layout box");
expect(dialogBox.width).toBeGreaterThanOrEqual(480);
await expect(dialog).toHaveClass(/font-sans/);
expect(testPage.url()).toBe(startUrl);
});
@@ -114,6 +114,73 @@ test.describe("Mobile sidebar task actions", () => {
expect(cardBox.y).toBeGreaterThan(0);
});
test("keeps the archive confirmation readable on a phone", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile archive dialog target", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.task_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = drawer
.getByTestId("sidebar-task-item")
.filter({ hasText: "Mobile archive dialog target" });
await taskRow.getByRole("button", { name: "Task actions" }).tap();
await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap();
const dialog = testPage.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await expect(dialog).toHaveClass(/font-sans/);
await expect(dialog.locator('[data-slot="alert-dialog-description"]')).toHaveClass(/text-sm/);
await dialog.evaluate(async (element) => {
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined)),
);
});
const [dialogBox, viewport] = await Promise.all([
dialog.boundingBox(),
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
]);
if (!dialogBox) throw new Error("archive confirmation dialog has no layout box");
expect(dialogBox.width).toBeGreaterThanOrEqual(viewport.width - 40);
expect(dialogBox.x).toBeGreaterThanOrEqual(8);
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(viewport.width - 8);
expect(dialogBox.y).toBeGreaterThanOrEqual(8);
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(viewport.height - 8);
const typography = await dialog.evaluate((element) => {
const description = element.querySelector('[data-slot="alert-dialog-description"]');
const cancel = element.querySelector('[data-slot="alert-dialog-cancel"]');
const action = element.querySelector('[data-slot="alert-dialog-action"]');
return {
dialogFontFamily: getComputedStyle(element).fontFamily,
descriptionFontFamily: description ? getComputedStyle(description).fontFamily : "",
descriptionFontSize: description ? getComputedStyle(description).fontSize : "",
cancelFontFamily: cancel ? getComputedStyle(cancel).fontFamily : "",
cancelFontSize: cancel ? getComputedStyle(cancel).fontSize : "",
actionFontFamily: action ? getComputedStyle(action).fontFamily : "",
actionFontSize: action ? getComputedStyle(action).fontSize : "",
};
});
expect(typography.dialogFontFamily).toContain("Figtree");
expect(typography.descriptionFontFamily).toBe(typography.dialogFontFamily);
expect(typography.cancelFontFamily).toBe(typography.dialogFontFamily);
expect(typography.descriptionFontSize).toBe(typography.cancelFontSize);
expect(typography.actionFontFamily).toBe(typography.dialogFontFamily);
expect(typography.actionFontSize).toBe(typography.descriptionFontSize);
});
test("keeps the tablet task switcher as a left-side sheet", async ({
testPage,
apiClient,