fix: refine task confirmation dialogs (#2553)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user