feat(ui): localize walkthrough discard confirmation (#2879)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionStatus } from "@/lib/types/connection";
|
||||
import type { TaskWalkthrough } from "@/lib/types/http";
|
||||
import { StateProvider, useAppStore } from "@/components/state-provider";
|
||||
import { deleteTaskWalkthrough, getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api";
|
||||
import { getOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walkthrough-open-state";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { WalkthroughOverlay } from "./walkthrough-overlay";
|
||||
|
||||
vi.mock("@/lib/api/domains/walkthrough-api", () => ({
|
||||
@@ -20,13 +21,37 @@ vi.mock("@/components/diff/walkthrough-floating-window", () => ({
|
||||
WalkthroughFloatingWindow: () => <div data-testid="walkthrough-floating" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: vi.fn(),
|
||||
}));
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
const SECOND_TASK_ID = "task-2";
|
||||
const LAUNCHER_TEST_ID = "walkthrough-launcher";
|
||||
const FLOATING_TEST_ID = "walkthrough-floating";
|
||||
const DISCARD_BUTTON_TEST_ID = "walkthrough-discard";
|
||||
const DISCARD_CONFIRMATION_TEST_ID = "walkthrough-discard-confirmation";
|
||||
const ALERT_DIALOG_ROLE = "alertdialog";
|
||||
const CANCEL_BUTTON_NAME = "Cancel";
|
||||
const DISCARD_BUTTON_NAME = "Discard walkthrough";
|
||||
|
||||
function walkthrough(): TaskWalkthrough {
|
||||
function responsive(isFinePointer: boolean): ReturnType<typeof useResponsiveBreakpoint> {
|
||||
return {
|
||||
id: "walkthrough-1",
|
||||
task_id: TASK_ID,
|
||||
breakpoint: isFinePointer ? "desktop" : "mobile",
|
||||
isMobile: !isFinePointer,
|
||||
isTablet: false,
|
||||
isDesktop: isFinePointer,
|
||||
isCompactDesktop: false,
|
||||
isFullDesktop: isFinePointer,
|
||||
isFinePointer,
|
||||
usesDesktopWorkbench: isFinePointer,
|
||||
};
|
||||
}
|
||||
|
||||
function walkthrough(taskId = TASK_ID): TaskWalkthrough {
|
||||
return {
|
||||
id: `walkthrough-${taskId}`,
|
||||
task_id: taskId,
|
||||
title: "Walkthrough",
|
||||
created_by: "agent",
|
||||
created_at: "2026-07-07T12:00:00Z",
|
||||
@@ -37,10 +62,13 @@ function walkthrough(): TaskWalkthrough {
|
||||
|
||||
let setConnectionStatus: ((status: ConnectionStatus) => void) | null = null;
|
||||
let setActiveTask: ((taskId: string) => void) | null = null;
|
||||
let setStoredWalkthrough: ((taskId: string, walkthrough: TaskWalkthrough | null) => void) | null =
|
||||
null;
|
||||
|
||||
function StoreProbe() {
|
||||
setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
|
||||
setActiveTask = useAppStore((s) => s.setActiveTask);
|
||||
setStoredWalkthrough = useAppStore((s) => s.setWalkthrough);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,6 +79,7 @@ function ActiveTaskOverlay() {
|
||||
|
||||
function renderOverlay({ followActiveTask = false } = {}) {
|
||||
setConnectionStatus = null;
|
||||
setStoredWalkthrough = null;
|
||||
return render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
@@ -69,12 +98,20 @@ function renderOverlay({ followActiveTask = false } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function resetOverlayMocks() {
|
||||
vi.mocked(getTaskWalkthrough).mockReset();
|
||||
vi.mocked(deleteTaskWalkthrough).mockReset();
|
||||
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(true));
|
||||
setOpenWalkthroughTaskId(null);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("WalkthroughOverlay", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTaskWalkthrough).mockReset();
|
||||
vi.mocked(deleteTaskWalkthrough).mockReset();
|
||||
setOpenWalkthroughTaskId(null);
|
||||
});
|
||||
beforeEach(resetOverlayMocks);
|
||||
|
||||
it("waits for websocket connection before backfilling the launcher", async () => {
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
@@ -86,7 +123,7 @@ describe("WalkthroughOverlay", () => {
|
||||
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
expect(await screen.findByTestId("walkthrough-launcher")).toBeTruthy();
|
||||
expect(await screen.findByTestId(LAUNCHER_TEST_ID)).toBeTruthy();
|
||||
expect(getTaskWalkthrough).toHaveBeenCalledWith(TASK_ID);
|
||||
});
|
||||
|
||||
@@ -95,12 +132,12 @@ describe("WalkthroughOverlay", () => {
|
||||
renderOverlay();
|
||||
await waitFor(() => expect(setConnectionStatus).toBeTruthy());
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
const launcher = await screen.findByTestId("walkthrough-launcher");
|
||||
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
|
||||
fireEvent.click(launcher);
|
||||
|
||||
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
|
||||
expect(screen.getByTestId("walkthrough-floating")).toBeTruthy();
|
||||
expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy();
|
||||
expect(launcher.className).not.toContain("bg-accent");
|
||||
const count = within(launcher).getByText("1/1");
|
||||
expect(count.className).toContain("text-foreground");
|
||||
@@ -117,7 +154,7 @@ describe("WalkthroughOverlay", () => {
|
||||
const view = renderOverlay({ followActiveTask: true });
|
||||
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
fireEvent.click(await within(view.container).findByTestId("walkthrough-launcher"));
|
||||
fireEvent.click(await within(view.container).findByTestId(LAUNCHER_TEST_ID));
|
||||
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
|
||||
|
||||
act(() => setActiveTask?.(SECOND_TASK_ID));
|
||||
@@ -125,3 +162,178 @@ describe("WalkthroughOverlay", () => {
|
||||
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalkthroughOverlay discard task binding", () => {
|
||||
beforeEach(resetOverlayMocks);
|
||||
|
||||
it("keeps discard bound to the task that opened the confirmation", async () => {
|
||||
vi.mocked(getTaskWalkthrough).mockImplementation(async (taskId) => walkthrough(taskId));
|
||||
vi.mocked(deleteTaskWalkthrough).mockResolvedValue(undefined);
|
||||
renderOverlay({ followActiveTask: true });
|
||||
act(() => {
|
||||
setStoredWalkthrough?.(SECOND_TASK_ID, walkthrough(SECOND_TASK_ID));
|
||||
setConnectionStatus?.("connected");
|
||||
});
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
||||
|
||||
act(() => setActiveTask?.(SECOND_TASK_ID));
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
await waitFor(() => expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull());
|
||||
expect(deleteTaskWalkthrough).not.toHaveBeenCalled();
|
||||
|
||||
act(() => setActiveTask?.(TASK_ID));
|
||||
|
||||
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
||||
fireEvent.click(
|
||||
within(confirmation).getByRole("button", {
|
||||
name: DISCARD_BUTTON_NAME,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledWith(TASK_ID));
|
||||
expect(deleteTaskWalkthrough).not.toHaveBeenCalledWith(SECOND_TASK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalkthroughOverlay fine-pointer discard confirmation", () => {
|
||||
beforeEach(resetOverlayMocks);
|
||||
|
||||
it("confirms fine-pointer discard in an anchored non-modal shell", async () => {
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
|
||||
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
||||
expect(confirmation.getAttribute("role")).toBe("dialog");
|
||||
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
|
||||
expect(launcher).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancels discard without closing the walkthrough", async () => {
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(launcher);
|
||||
await waitFor(() => expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy());
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
|
||||
name: CANCEL_BUTTON_NAME,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull());
|
||||
expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy();
|
||||
expect(getOpenWalkthroughTaskId()).toBe(TASK_ID);
|
||||
});
|
||||
|
||||
it("closes the local shell before discarding once", async () => {
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
let shellClosedBeforeDelete = false;
|
||||
vi.mocked(deleteTaskWalkthrough).mockImplementation(async () => {
|
||||
shellClosedBeforeDelete = screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID) === null;
|
||||
});
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
|
||||
name: DISCARD_BUTTON_NAME,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledTimes(1));
|
||||
expect(shellClosedBeforeDelete).toBe(true);
|
||||
expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(LAUNCHER_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(FLOATING_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
|
||||
beforeEach(resetOverlayMocks);
|
||||
|
||||
it("morphs coarse-pointer discard into local touch actions", async () => {
|
||||
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
|
||||
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
||||
expect(confirmation.getAttribute("role")).toBe("group");
|
||||
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
|
||||
expect(
|
||||
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
|
||||
).toContain("h-11");
|
||||
});
|
||||
|
||||
it("restores focus to the coarse-pointer discard button after cancel", async () => {
|
||||
let runAnimationFrame: FrameRequestCallback | null = null;
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
runAnimationFrame = callback;
|
||||
return 0;
|
||||
});
|
||||
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
fireEvent.click(
|
||||
within(await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
|
||||
name: CANCEL_BUTTON_NAME,
|
||||
}),
|
||||
);
|
||||
act(() => runAnimationFrame?.(0));
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalkthroughOverlay discard mutation state", () => {
|
||||
beforeEach(resetOverlayMocks);
|
||||
|
||||
it("disables repeated discard while deletion is in flight", async () => {
|
||||
let finishDelete: (() => void) | null = null;
|
||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||
vi.mocked(deleteTaskWalkthrough).mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
}),
|
||||
);
|
||||
renderOverlay();
|
||||
act(() => setConnectionStatus?.("connected"));
|
||||
|
||||
await screen.findByTestId(LAUNCHER_TEST_ID);
|
||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||
fireEvent.click(
|
||||
within(await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
|
||||
name: DISCARD_BUTTON_NAME,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledTimes(1));
|
||||
const disabledDuringDelete = (screen.getByTestId(DISCARD_BUTTON_TEST_ID) as HTMLButtonElement)
|
||||
.disabled;
|
||||
|
||||
act(() => finishDelete?.());
|
||||
await waitFor(() => expect(screen.queryByTestId(LAUNCHER_TEST_ID)).toBeNull());
|
||||
expect(disabledDuringDelete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { IconRoute, IconX } from "@tabler/icons-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { deleteTaskWalkthrough, getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api";
|
||||
@@ -20,6 +10,9 @@ import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walk
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import type { TaskWalkthrough } from "@/lib/types/http";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
type WalkthroughOverlayProps = {
|
||||
/** The task whose walkthrough launcher should be shown. */
|
||||
@@ -31,22 +24,40 @@ type WalkthroughOverlayProps = {
|
||||
|
||||
type WalkthroughLauncherProps = {
|
||||
activeStep: number;
|
||||
confirmDiscardOpen: boolean;
|
||||
discardAnchorRef: RefObject<HTMLButtonElement | null>;
|
||||
discardDisabled: boolean;
|
||||
hasUnseen: boolean;
|
||||
isFinePointer: boolean;
|
||||
isOpen: boolean;
|
||||
onDiscardClick: () => void;
|
||||
onDiscardCancel: () => void;
|
||||
onDiscardClose: () => void;
|
||||
onDiscardConfirm: () => void | Promise<void>;
|
||||
onToggle: () => void;
|
||||
stepCount: number;
|
||||
};
|
||||
|
||||
function WalkthroughLauncher({
|
||||
activeStep,
|
||||
confirmDiscardOpen,
|
||||
discardAnchorRef,
|
||||
discardDisabled,
|
||||
hasUnseen,
|
||||
isFinePointer,
|
||||
isOpen,
|
||||
onDiscardClick,
|
||||
onDiscardCancel,
|
||||
onDiscardClose,
|
||||
onDiscardConfirm,
|
||||
onToggle,
|
||||
stepCount,
|
||||
}: WalkthroughLauncherProps) {
|
||||
const { t } = useTranslation();
|
||||
const discardLabel = t("review:discardWalkthrough");
|
||||
const discardVisibility = isFinePointer
|
||||
? "opacity-0 pointer-events-none scale-90 group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
: "";
|
||||
return (
|
||||
<div className="group fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-[41]">
|
||||
<button
|
||||
@@ -79,67 +90,45 @@ function WalkthroughLauncher({
|
||||
{activeStep + 1}/{stepCount}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("review:discardWalkthrough")}
|
||||
title={t("review:discardWalkthrough")}
|
||||
data-testid="walkthrough-discard"
|
||||
onClick={onDiscardClick}
|
||||
className={cn(
|
||||
"absolute -right-3 -top-3 flex size-9 cursor-pointer items-center justify-center rounded-full",
|
||||
"border border-border bg-card text-muted-foreground shadow-md transition-all",
|
||||
"hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||
"sm:size-7 sm:opacity-0 sm:pointer-events-none sm:scale-90",
|
||||
"sm:group-hover:pointer-events-auto sm:group-hover:scale-100 sm:group-hover:opacity-100",
|
||||
"sm:group-focus-within:pointer-events-auto sm:group-focus-within:scale-100 sm:group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<IconX className="size-4" />
|
||||
</button>
|
||||
{isFinePointer || !confirmDiscardOpen ? (
|
||||
<button
|
||||
ref={discardAnchorRef}
|
||||
type="button"
|
||||
aria-label={discardLabel}
|
||||
title={discardLabel}
|
||||
data-testid="walkthrough-discard"
|
||||
disabled={discardDisabled}
|
||||
onClick={onDiscardClick}
|
||||
className={cn(
|
||||
"absolute -right-3 -top-3 flex cursor-pointer items-center justify-center rounded-full",
|
||||
isFinePointer ? "h-7 min-h-7 w-7 min-w-7" : "h-11 min-h-11 w-11 min-w-11",
|
||||
"border border-border bg-card text-muted-foreground shadow-md transition-all",
|
||||
"hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45",
|
||||
discardVisibility,
|
||||
)}
|
||||
>
|
||||
<IconX className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="absolute bottom-full right-0 z-[1] mb-2 rounded-lg border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm">
|
||||
<InlineConfirmActions
|
||||
density="touch"
|
||||
testId="walkthrough-discard-confirmation"
|
||||
ariaLabel={t("review:discardWalkthroughTitle")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={discardLabel}
|
||||
confirmAriaLabel={discardLabel}
|
||||
onCancel={onDiscardCancel}
|
||||
onClose={onDiscardClose}
|
||||
onConfirm={onDiscardConfirm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DiscardWalkthroughDialogProps = {
|
||||
discarding: boolean;
|
||||
onConfirm: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
function DiscardWalkthroughDialog({
|
||||
discarding,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: DiscardWalkthroughDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent data-testid="walkthrough-discard-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("review:discardWalkthroughTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("review:discardWalkthroughDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer" disabled={discarding}>
|
||||
{t("common:cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={discarding}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("review:discardWalkthrough")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useWalkthroughBackfill(params: {
|
||||
connectionStatus: string;
|
||||
setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void;
|
||||
@@ -189,6 +178,8 @@ function useWalkthroughBackfill(params: {
|
||||
*/
|
||||
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const discardLabel = t("review:discardWalkthrough");
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
|
||||
const connectionStatus = useAppStore((s) => s.connection.status);
|
||||
const activeStep = useAppStore((s) =>
|
||||
@@ -199,10 +190,12 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
|
||||
);
|
||||
const setWalkthrough = useAppStore((s) => s.setWalkthrough);
|
||||
const [openTaskId, setOpenTaskId] = useState<string | null>(null);
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
||||
const [discardTargetTaskId, setDiscardTargetTaskId] = useState<string | null>(null);
|
||||
const [discarding, setDiscarding] = useState(false);
|
||||
const discardAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { toast } = useToast();
|
||||
const open = taskId !== null && openTaskId === taskId;
|
||||
const confirmDiscardOpen = taskId !== null && discardTargetTaskId === taskId;
|
||||
|
||||
useWalkthroughBackfill({ connectionStatus, setWalkthrough, taskId, walkthrough });
|
||||
|
||||
@@ -231,16 +224,15 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
|
||||
clearOpenWalkthroughTaskId(taskId);
|
||||
setOpenTaskId(null);
|
||||
};
|
||||
const confirmDiscard = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
if (!taskId || discarding) return;
|
||||
const discardWalkthrough = async (targetTaskId: string | null) => {
|
||||
if (!targetTaskId || discarding) return;
|
||||
setDiscarding(true);
|
||||
try {
|
||||
await deleteTaskWalkthrough(taskId);
|
||||
clearOpenWalkthroughTaskId(taskId);
|
||||
setOpenTaskId(null);
|
||||
setWalkthrough(taskId, null);
|
||||
setConfirmDiscardOpen(false);
|
||||
await deleteTaskWalkthrough(targetTaskId);
|
||||
clearOpenWalkthroughTaskId(targetTaskId);
|
||||
setOpenTaskId((currentTaskId) => (currentTaskId === targetTaskId ? null : currentTaskId));
|
||||
setWalkthrough(targetTaskId, null);
|
||||
setDiscardTargetTaskId(null);
|
||||
toast({ title: t("review:walkthroughDiscarded"), variant: "success" });
|
||||
} catch (error) {
|
||||
console.error("Failed to discard walkthrough:", error);
|
||||
@@ -255,18 +247,36 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
|
||||
{open ? <WalkthroughFloatingWindow onClose={closeTour} onSelectFile={onSelectFile} /> : null}
|
||||
<WalkthroughLauncher
|
||||
activeStep={activeStep}
|
||||
confirmDiscardOpen={confirmDiscardOpen}
|
||||
discardAnchorRef={discardAnchorRef}
|
||||
discardDisabled={discarding}
|
||||
hasUnseen={hasUnseen}
|
||||
isFinePointer={isFinePointer}
|
||||
isOpen={open}
|
||||
onDiscardClick={() => setConfirmDiscardOpen(true)}
|
||||
onDiscardClick={() => setDiscardTargetTaskId(taskId)}
|
||||
onDiscardCancel={() => {
|
||||
setDiscardTargetTaskId(null);
|
||||
requestAnimationFrame(() => discardAnchorRef.current?.focus());
|
||||
}}
|
||||
onDiscardClose={() => setDiscardTargetTaskId(null)}
|
||||
onDiscardConfirm={() => discardWalkthrough(discardTargetTaskId)}
|
||||
onToggle={() => (open ? closeTour() : openTour())}
|
||||
stepCount={walkthrough.steps.length}
|
||||
/>
|
||||
<DiscardWalkthroughDialog
|
||||
discarding={discarding}
|
||||
onConfirm={confirmDiscard}
|
||||
onOpenChange={setConfirmDiscardOpen}
|
||||
open={confirmDiscardOpen}
|
||||
/>
|
||||
{isFinePointer ? (
|
||||
<ActionConfirmPopover
|
||||
open={confirmDiscardOpen}
|
||||
anchorRef={discardAnchorRef}
|
||||
title={t("review:discardWalkthroughTitle")}
|
||||
description={t("review:discardWalkthroughDescription")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={discardLabel}
|
||||
confirmTestId="walkthrough-discard-confirm"
|
||||
testId="walkthrough-discard-confirmation"
|
||||
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
|
||||
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1089,8 +1089,8 @@ export class SessionPage {
|
||||
return this.page.getByTestId("walkthrough-discard");
|
||||
}
|
||||
|
||||
walkthroughDiscardDialog(): Locator {
|
||||
return this.page.getByRole("alertdialog", { name: "Discard walkthrough?" });
|
||||
walkthroughDiscardConfirmation(): Locator {
|
||||
return this.page.locator('[data-testid="walkthrough-discard-confirmation"]:visible');
|
||||
}
|
||||
|
||||
walkthroughFloating(): Locator {
|
||||
|
||||
@@ -155,18 +155,41 @@ test.describe("Mobile code walkthrough", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
await seedWalkthroughTask(testPage, apiClient, seedData);
|
||||
const session = new SessionPage(testPage);
|
||||
|
||||
await expect(session.walkthroughLauncher()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 });
|
||||
await session.walkthroughDiscardButton().click();
|
||||
await expect(session.walkthroughDiscardDialog()).toBeVisible();
|
||||
await session.walkthroughDiscardButton().tap();
|
||||
const discardConfirmation = session.walkthroughDiscardConfirmation();
|
||||
await expect(discardConfirmation).toBeVisible();
|
||||
await expect(discardConfirmation).toHaveAttribute("role", "group");
|
||||
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
||||
await prCapture.screenshot("mobile-walkthrough-discard-confirmation", {
|
||||
caption: "Mobile walkthrough keeps discard confirmation inside its touch launcher",
|
||||
});
|
||||
const actionBoxes = await Promise.all(
|
||||
["Cancel", "Discard walkthrough"].map((name) =>
|
||||
discardConfirmation.getByRole("button", { name }).boundingBox(),
|
||||
),
|
||||
);
|
||||
for (const box of actionBoxes) {
|
||||
if (!box) throw new Error("walkthrough discard action geometry unavailable");
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await discardConfirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(discardConfirmation).toHaveCount(0);
|
||||
await expect(session.walkthroughLauncher()).toBeVisible();
|
||||
|
||||
await session.walkthroughDiscardButton().tap();
|
||||
await expect(session.walkthroughDiscardConfirmation()).toBeVisible();
|
||||
await session
|
||||
.walkthroughDiscardDialog()
|
||||
.walkthroughDiscardConfirmation()
|
||||
.getByRole("button", { name: "Discard walkthrough" })
|
||||
.click();
|
||||
.tap();
|
||||
|
||||
await expect(session.walkthroughLauncher()).toHaveCount(0);
|
||||
await expect(session.walkthroughFloating()).toHaveCount(0);
|
||||
|
||||
@@ -397,6 +397,7 @@ test.describe("Code walkthrough", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const session = await seedWalkthroughTask(
|
||||
testPage,
|
||||
@@ -438,25 +439,36 @@ test.describe("Code walkthrough", () => {
|
||||
await session.walkthroughLauncher().hover();
|
||||
await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 });
|
||||
await session.walkthroughDiscardButton().click();
|
||||
const discardDialog = session.walkthroughDiscardDialog();
|
||||
await expect(discardDialog).toBeVisible();
|
||||
await expectWalkthroughBehindDialog(testPage, discardDialog, [
|
||||
{ locator: card, name: "walkthrough window" },
|
||||
{ locator: session.walkthroughLauncher().locator(".."), name: "walkthrough launcher" },
|
||||
const discardConfirmation = session.walkthroughDiscardConfirmation();
|
||||
await expect(discardConfirmation).toBeVisible();
|
||||
await expect(discardConfirmation).toHaveAttribute("role", "dialog");
|
||||
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
||||
await prCapture.screenshot("desktop-walkthrough-discard-confirmation", {
|
||||
caption: "Desktop walkthrough confirms discard in its anchored toolbar",
|
||||
});
|
||||
const [confirmationZIndex, launcherZIndex] = await Promise.all([
|
||||
discardConfirmation.evaluate((element) =>
|
||||
Number.parseInt(getComputedStyle(element).zIndex, 10),
|
||||
),
|
||||
session
|
||||
.walkthroughLauncher()
|
||||
.locator("..")
|
||||
.evaluate((element) => Number.parseInt(getComputedStyle(element).zIndex, 10)),
|
||||
]);
|
||||
await discardDialog.getByRole("button", { name: "Cancel" }).click();
|
||||
expect(confirmationZIndex).toBeGreaterThan(launcherZIndex);
|
||||
await discardConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(session.walkthroughLauncher()).toHaveCount(1);
|
||||
|
||||
await session.walkthroughLauncher().hover();
|
||||
await session.walkthroughDiscardButton().click();
|
||||
await expect(session.walkthroughDiscardDialog()).toBeVisible();
|
||||
await expect(session.walkthroughDiscardConfirmation()).toBeVisible();
|
||||
await session
|
||||
.walkthroughDiscardDialog()
|
||||
.walkthroughDiscardConfirmation()
|
||||
.getByRole("button", { name: "Discard walkthrough" })
|
||||
.click();
|
||||
|
||||
await expect(session.walkthroughDiscardDialog()).toBeHidden({ timeout: 10_000 });
|
||||
await expect(session.walkthroughDiscardConfirmation()).toBeHidden({ timeout: 10_000 });
|
||||
await expect(session.walkthroughLauncher()).toHaveCount(0);
|
||||
await expect(session.walkthroughFloating()).toHaveCount(0);
|
||||
await expect(session.walkthroughEditorRange()).toHaveCount(0);
|
||||
|
||||
Reference in New Issue
Block a user