feat(ui): add localized action confirmations (#2818)

This commit is contained in:
José Almeida
2026-08-20 17:24:06 +01:00
committed by GitHub
parent b2f4bb3e3b
commit 34b17ab774
19 changed files with 1063 additions and 238 deletions
@@ -90,7 +90,10 @@ export function useWorkspaceRepositorySets({ workspaceId }: { workspaceId: strin
}
}, [draft, saving, workspaceId, upsertRepositorySet, t]);
const startDelete = useCallback((set: RepositorySet) => setDeleting(set), []);
const startDelete = useCallback((set: RepositorySet) => {
setError(null);
setDeleting(set);
}, []);
const cancelDelete = useCallback(() => setDeleting(null), []);
const confirmDelete = useCallback(async () => {
@@ -1,68 +1,49 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import type { RepositorySet } from "@/lib/types/http";
type RepositorySetDeleteDialogProps = {
set: RepositorySet | null;
/** Non-null when the last delete attempt failed; keeps the dialog open. */
error: string | null;
onClose: () => void;
onConfirm: () => void;
type RepositorySetDeleteConfirmationProps = {
set: RepositorySet;
open: boolean;
anchorRef: RefObject<HTMLElement | null>;
onOpenChange: (open: boolean) => void;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
};
/**
* Confirms deleting a set. The copy states plainly that no repository is
* affected, because "delete set" next to a list of repository names reads as
* though it might remove them.
* Fine-pointer confirmation for deleting one repository set. The copy states
* plainly that no repository is affected, because "delete set" next to a list
* of repository names reads as though it might remove them.
*/
export function RepositorySetDeleteDialog({
export function RepositorySetDeleteConfirmation({
set,
error,
onClose,
open,
anchorRef,
onOpenChange,
onCancel,
onConfirm,
}: RepositorySetDeleteDialogProps) {
}: RepositorySetDeleteConfirmationProps) {
const { t } = useTranslation();
if (!set) return null;
return (
<AlertDialog open onOpenChange={(next) => (next ? undefined : onClose())}>
<AlertDialogContent data-testid="repository-set-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("workspaces:repositorySetsDeleteTitle", { name: set.name })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("workspaces:repositorySetsDeleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
{error ? (
<p className="text-xs text-destructive" data-testid="repository-set-delete-error">
{error}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
onClick={onConfirm}
data-testid="repository-set-delete-confirm"
>
{t("workspaces:repositorySetsDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={t("workspaces:repositorySetsDeleteTitle", { name: set.name })}
description={t("workspaces:repositorySetsDeleteDescription")}
cancelLabel={t("common:cancel")}
confirmLabel={t("workspaces:repositorySetsDelete")}
confirmAriaLabel={t("workspaces:repositorySetsDeleteTitle", { name: set.name })}
confirmTestId="repository-set-delete-confirm"
testId="repository-set-delete-confirm-popover"
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { Repository, RepositorySet } from "@/lib/types/http";
import { repositoryId, workspaceId } from "@/lib/types/ids";
@@ -7,10 +7,17 @@ import { repositoryId, workspaceId } from "@/lib/types/ids";
const REPO_WEB = "repo-web";
const REPO_GATEWAY = "repo-gateway";
const FULL_STACK = "Full-stack";
const BACKEND = "Backend";
const NAME_INPUT = "repository-set-editor-name";
const SAVE = "repository-set-editor-save";
const SET_ROW = "repository-set-row";
const CREATE = "repository-set-create";
const DELETE = "repository-set-delete-set-1";
const DELETE_SECOND = "repository-set-delete-set-2";
const DELETE_CONFIRM_POPOVER = "repository-set-delete-confirm-popover";
const DELETE_CONFIRM = "repository-set-delete-confirm";
const DELETE_INLINE = "repository-set-delete-inline-confirmation";
const DELETE_ERROR = "repository-set-delete-error";
const mockCreate = vi.fn();
const mockUpdate = vi.fn();
@@ -18,6 +25,7 @@ const mockDelete = vi.fn();
const mockUpsert = vi.fn();
const mockRemove = vi.fn();
let mockSets: RepositorySet[] = [];
let finePointer = true;
vi.mock("@/lib/api", () => ({
createRepositorySet: (...args: unknown[]) => mockCreate(...args),
@@ -29,6 +37,10 @@ vi.mock("@/hooks/domains/workspace/use-repository-sets", () => ({
useRepositorySets: () => ({ sets: mockSets, isLoading: false, refresh: vi.fn() }),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
}));
vi.mock("@/components/state-provider", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useAppStore: (selector: (state: any) => unknown) =>
@@ -70,6 +82,7 @@ function renderSection(options: { readOnly?: boolean } = {}) {
beforeEach(() => {
vi.clearAllMocks();
finePointer = true;
mockSets = [repositorySet("set-1", FULL_STACK, [REPO_WEB, REPO_GATEWAY])];
mockCreate.mockResolvedValue(repositorySet("set-2", "Backend", [REPO_GATEWAY]));
mockUpdate.mockResolvedValue(repositorySet("set-1", "Renamed", [REPO_WEB]));
@@ -163,13 +176,18 @@ describe("WorkspaceRepositorySetsSection", () => {
expect(screen.queryByTestId(NAME_INPUT)).not.toBeNull();
expect(mockUpsert).not.toHaveBeenCalled();
});
});
describe("WorkspaceRepositorySetsSection deletion confirmation", () => {
it("deletes a set after confirmation and drops it from the store", async () => {
renderSection();
fireEvent.click(screen.getByTestId("repository-set-delete-set-1"));
fireEvent.click(screen.getByTestId("repository-set-delete-confirm"));
fireEvent.click(screen.getByTestId(DELETE));
const popover = screen.getByTestId(DELETE_CONFIRM_POPOVER);
expect(screen.queryByRole("alertdialog")).toBeNull();
fireEvent.click(within(popover).getByTestId(DELETE_CONFIRM));
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER)).toBeNull();
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith("set-1"));
expect(mockRemove).toHaveBeenCalledWith("ws-1", "set-1");
});
@@ -177,18 +195,87 @@ describe("WorkspaceRepositorySetsSection", () => {
it("states that deleting a set leaves its repositories alone", () => {
renderSection();
fireEvent.click(screen.getByTestId("repository-set-delete-set-1"));
fireEvent.click(screen.getByTestId(DELETE));
const dialog = screen.getByTestId("repository-set-delete-dialog");
expect(dialog.textContent).toContain("repositories");
const popover = screen.getByTestId(DELETE_CONFIRM_POPOVER);
expect(popover.textContent).toContain("repositories");
expect(screen.queryByTestId("repository-set-delete-dialog")).toBeNull();
});
it("cancels locally and returns focus to the delete control", async () => {
renderSection();
const trigger = screen.getByTestId(DELETE);
fireEvent.click(trigger);
fireEvent.click(
within(screen.getByTestId(DELETE_CONFIRM_POPOVER)).getByRole("button", {
name: "Cancel",
}),
);
expect(mockDelete).not.toHaveBeenCalled();
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
it("keeps the row and shows local failure feedback when deletion fails", async () => {
const { ApiError } = await import("@/lib/api/client");
mockDelete.mockRejectedValue(new ApiError("server error", 500, null));
renderSection();
fireEvent.click(screen.getByTestId(DELETE));
fireEvent.click(within(screen.getByTestId(DELETE_CONFIRM_POPOVER)).getByTestId(DELETE_CONFIRM));
await waitFor(() => expect(screen.queryByTestId(DELETE_ERROR)).not.toBeNull());
expect(
screen.getByTestId(SET_ROW).querySelector(`[data-testid="${DELETE_ERROR}"]`),
).not.toBeNull();
expect(mockRemove).not.toHaveBeenCalled();
});
it("morphs the delete action into touch-sized inline confirmation on coarse pointers", async () => {
finePointer = false;
renderSection();
fireEvent.click(screen.getByTestId(DELETE));
const inline = screen.getByTestId(DELETE_INLINE);
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER)).toBeNull();
expect(inline.textContent).toContain("repositories");
expect(within(inline).getByTestId(DELETE_CONFIRM).className).toContain("h-11");
expect(within(inline).getByTestId(DELETE_CONFIRM).className).toContain("min-w-11");
fireEvent.click(within(inline).getByRole("button", { name: "Cancel" }));
expect(mockDelete).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId(DELETE));
fireEvent.click(within(screen.getByTestId(DELETE_INLINE)).getByTestId(DELETE_CONFIRM));
await waitFor(() => expect(mockRemove).toHaveBeenCalledWith("ws-1", "set-1"));
});
it("clears a previous deletion error before confirming a different set", async () => {
const first = repositorySet("set-1", FULL_STACK, [REPO_WEB]);
const second = repositorySet("set-2", BACKEND, [REPO_GATEWAY]);
mockSets = [first, second];
mockDelete.mockRejectedValueOnce(new Error("server error"));
renderSection();
fireEvent.click(screen.getByTestId(DELETE));
fireEvent.click(within(screen.getByTestId(DELETE_CONFIRM_POPOVER)).getByTestId(DELETE_CONFIRM));
await waitFor(() => expect(screen.queryByTestId(DELETE_ERROR)).not.toBeNull());
fireEvent.click(screen.getByTestId(DELETE_SECOND));
expect(screen.queryByTestId(DELETE_ERROR)).toBeNull();
});
});
describe("WorkspaceRepositorySetsSection read-only behavior", () => {
it("offers no create, edit, or delete control when read-only", () => {
renderSection({ readOnly: true });
expect(screen.queryByTestId(CREATE)).toBeNull();
expect(screen.queryByTestId("repository-set-edit-set-1")).toBeNull();
expect(screen.queryByTestId("repository-set-delete-set-1")).toBeNull();
expect(screen.queryByTestId(DELETE)).toBeNull();
// The list itself still renders, so the sets remain visible.
expect(screen.queryAllByTestId(SET_ROW)).toHaveLength(1);
});
@@ -1,16 +1,19 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { IconPencil, IconStack2, IconTrash } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { SettingsSection } from "@/components/settings/settings-section";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { Repository, RepositorySet } from "@/lib/types/http";
import { useRepositorySets } from "@/hooks/domains/workspace/use-repository-sets";
import { RepositorySetEditorDialog } from "./workspace-repository-set-editor";
import { RepositorySetDeleteDialog } from "./workspace-repository-set-delete";
import { useWorkspaceRepositorySets } from "./use-workspace-repository-sets";
import { RepositorySetEditorDialog } from "./workspace-repository-set-editor";
import { RepositorySetDeleteConfirmation } from "./workspace-repository-set-delete";
type WorkspaceRepositorySetsSectionProps = {
workspaceId: string;
@@ -32,8 +35,35 @@ export function WorkspaceRepositorySetsSection({
readOnly,
}: WorkspaceRepositorySetsSectionProps) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { sets } = useRepositorySets(workspaceId);
const manager = useWorkspaceRepositorySets({ workspaceId });
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (!confirmingDeleteId || sets.some((set) => set.id === confirmingDeleteId)) return;
setConfirmingDeleteId(null);
manager.cancelDelete();
}, [confirmingDeleteId, manager, sets]);
const startDelete = (set: RepositorySet) => {
manager.startDelete(set);
setConfirmingDeleteId(set.id);
};
const cancelDelete = () => {
setConfirmingDeleteId(null);
manager.cancelDelete();
};
const closeDeleteConfirmation = () => {
setConfirmingDeleteId(null);
};
const confirmDelete = () => {
setConfirmingDeleteId(null);
void manager.confirmDelete();
};
return (
<SettingsSection
@@ -66,8 +96,14 @@ export function WorkspaceRepositorySetsSection({
set={set}
repositories={repositories}
readOnly={readOnly}
isFinePointer={isFinePointer}
confirmingDelete={confirmingDeleteId === set.id}
deleteError={manager.deleting?.id === set.id ? manager.error : null}
onEdit={() => manager.startEdit(set)}
onDelete={() => manager.startDelete(set)}
onDelete={() => startDelete(set)}
onDeleteCancel={cancelDelete}
onDeleteOpenChange={closeDeleteConfirmation}
onDeleteConfirm={confirmDelete}
/>
))}
</div>
@@ -81,12 +117,6 @@ export function WorkspaceRepositorySetsSection({
onChange={manager.updateDraft}
onSubmit={manager.submitDraft}
/>
<RepositorySetDeleteDialog
set={manager.deleting}
error={manager.deleting ? manager.error : null}
onClose={manager.cancelDelete}
onConfirm={manager.confirmDelete}
/>
</SettingsSection>
);
}
@@ -95,8 +125,14 @@ type RepositorySetRowProps = {
set: RepositorySet;
repositories: Repository[];
readOnly: boolean;
isFinePointer: boolean;
confirmingDelete: boolean;
deleteError: string | null;
onEdit: () => void;
onDelete: () => void;
onDeleteCancel: () => void;
onDeleteOpenChange: (open: boolean) => void;
onDeleteConfirm: () => void;
};
/**
@@ -108,10 +144,17 @@ function RepositorySetRow({
set,
repositories,
readOnly,
isFinePointer,
confirmingDelete,
deleteError,
onEdit,
onDelete,
onDeleteCancel,
onDeleteOpenChange,
onDeleteConfirm,
}: RepositorySetRowProps) {
const { t } = useTranslation();
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const namesById = new Map(
repositories.map((repository) => [repository.id as string, repository]),
);
@@ -129,31 +172,36 @@ function RepositorySetRow({
<p className="text-xs text-muted-foreground">{set.description}</p>
) : null}
</div>
{readOnly ? null : (
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
aria-label={t("workspaces:repositorySetsEdit")}
onClick={onEdit}
data-testid={`repository-set-edit-${set.id}`}
>
<IconPencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
aria-label={t("workspaces:repositorySetsDelete")}
onClick={onDelete}
data-testid={`repository-set-delete-${set.id}`}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
)}
<RepositorySetRowActions
set={set}
readOnly={readOnly}
isFinePointer={isFinePointer}
confirmingDelete={confirmingDelete}
deleteAnchorRef={deleteAnchorRef}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
{!isFinePointer && confirmingDelete ? (
<InlineConfirmActions
density="touch"
testId="repository-set-delete-inline-confirmation"
ariaLabel={t("workspaces:repositorySetsDeleteTitle", { name: set.name })}
description={t("workspaces:repositorySetsDeleteDescription")}
cancelLabel={t("common:cancel")}
confirmLabel={t("workspaces:repositorySetsDelete")}
confirmAriaLabel={t("workspaces:repositorySetsDeleteTitle", { name: set.name })}
confirmTestId="repository-set-delete-confirm"
onCancel={onDeleteCancel}
onClose={() => onDeleteOpenChange(false)}
onConfirm={onDeleteConfirm}
/>
) : null}
{deleteError ? (
<p className="mt-2 text-xs text-destructive" data-testid="repository-set-delete-error">
{deleteError}
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-1.5">
{set.repositories.length === 0 ? (
<span className="text-xs text-muted-foreground">
@@ -167,6 +215,67 @@ function RepositorySetRow({
))
)}
</div>
{isFinePointer ? (
<RepositorySetDeleteConfirmation
set={set}
open={confirmingDelete}
anchorRef={deleteAnchorRef}
onOpenChange={onDeleteOpenChange}
onCancel={onDeleteCancel}
onConfirm={onDeleteConfirm}
/>
) : null}
</div>
);
}
type RepositorySetRowActionsProps = {
set: RepositorySet;
readOnly: boolean;
isFinePointer: boolean;
confirmingDelete: boolean;
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onEdit: () => void;
onDelete: () => void;
};
function RepositorySetRowActions({
set,
readOnly,
isFinePointer,
confirmingDelete,
deleteAnchorRef,
onEdit,
onDelete,
}: RepositorySetRowActionsProps) {
const { t } = useTranslation();
if (readOnly) return null;
// Fine pointers keep the Delete button as the popover anchor. Coarse
// pointers replace that button with the row-owned inline confirmation.
if (!isFinePointer && confirmingDelete) return null;
return (
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
aria-label={t("workspaces:repositorySetsEdit")}
onClick={onEdit}
data-testid={`repository-set-edit-${set.id}`}
>
<IconPencil className="h-4 w-4" />
</Button>
<Button
ref={deleteAnchorRef}
size="sm"
variant="ghost"
className="cursor-pointer"
aria-label={t("workspaces:repositorySetsDelete")}
onClick={onDelete}
data-testid={`repository-set-delete-${set.id}`}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,213 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ActionConfirmPopover } from "./action-confirm-popover";
const deleteTitle = "Delete item?";
function Harness({ onConfirm = vi.fn() }: { onConfirm?: () => void | Promise<void> }) {
const [open, setOpen] = useState(true);
const [showAnchor, setShowAnchor] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
{showAnchor ? (
<button ref={anchorRef} type="button" data-testid="anchor" onClick={() => setOpen(true)}>
Delete
</button>
) : null}
<button type="button" onClick={() => setShowAnchor(false)}>
Remove anchor
</button>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={onConfirm}
/>
</>
);
}
describe("ActionConfirmPopover", () => {
afterEach(cleanup);
it("focuses Cancel first and gives coarse pointers a touch-sized action", async () => {
render(<Harness />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })),
);
expect(
within(screen.getByRole("dialog")).getByRole("button", { name: "Delete" }).className,
).toContain("min-h-11");
});
it("closes before invoking an unresolved confirmation and returns focus on cancel", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
render(<Harness onConfirm={onConfirm} />);
const cancel = screen.getByRole("button", { name: "Cancel" });
const anchor = screen.getByTestId("anchor");
fireEvent.click(cancel);
expect(onConfirm).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog", { name: deleteTitle })).toBeNull();
await waitFor(() => expect(document.activeElement).toBe(anchor));
});
it("removes the shell before invoking the confirmation callback", async () => {
let shellClosed = false;
const onConfirm = vi.fn(() => {
shellClosed = screen.queryByRole("dialog", { name: deleteTitle }) === null;
});
render(<Harness onConfirm={onConfirm} />);
fireEvent.click(
within(screen.getByRole("dialog", { name: deleteTitle })).getByRole("button", {
name: "Delete",
}),
);
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(shellClosed).toBe(true);
});
it("handles a rejected callback without an unhandled rejection", async () => {
const unhandled = vi.fn();
const onUnhandled = (event: PromiseRejectionEvent) => {
event.preventDefault();
unhandled(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandled);
const onConfirm = vi.fn(() => Promise.reject(new Error("delete failed")));
render(<Harness onConfirm={onConfirm} />);
fireEvent.click(
within(screen.getByRole("dialog", { name: deleteTitle })).getByRole("button", {
name: "Delete",
}),
);
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(unhandled).not.toHaveBeenCalled();
window.removeEventListener("unhandledrejection", onUnhandled);
});
});
describe("ActionConfirmPopover anchor lifecycle", () => {
afterEach(cleanup);
it("dismisses without confirming when the anchor disappears", async () => {
function DisappearingHarness() {
const [open, setOpen] = useState(true);
const [showAnchor, setShowAnchor] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const onConfirm = vi.fn();
return (
<>
{showAnchor ? (
<button ref={anchorRef} type="button" onClick={() => setShowAnchor(false)}>
Remove target
</button>
) : null}
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={onConfirm}
/>
</>
);
}
render(<DisappearingHarness />);
fireEvent.click(screen.getByRole("button", { name: "Remove target" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("ActionConfirmPopover focus boundaries", () => {
afterEach(cleanup);
it("keeps the popover open while focus moves inside the optional menu boundary", async () => {
function MenuHarness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
return (
<>
<button ref={anchorRef} type="button">
Delete
</button>
<div ref={menuRef} data-testid="menu-boundary" tabIndex={0}>
Menu
</div>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={menuRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={vi.fn()}
/>
</>
);
}
render(<MenuHarness />);
screen.getByTestId("menu-boundary").focus();
await waitFor(() => expect(screen.getByRole("dialog", { name: deleteTitle })).toBeTruthy());
});
it("dismisses when focus moves outside the optional menu boundary", async () => {
function MenuHarness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
return (
<>
<button ref={anchorRef} type="button">
Delete
</button>
<div ref={menuRef} data-testid="menu-boundary" tabIndex={0}>
Menu
</div>
<button type="button" data-testid="outside">
Outside
</button>
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={menuRef}
title={deleteTitle}
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onOpenChange={setOpen}
onConfirm={vi.fn()}
/>
</>
);
}
render(<MenuHarness />);
screen.getByTestId("outside").focus();
await waitFor(() => expect(screen.queryByRole("dialog", { name: deleteTitle })).toBeNull());
});
});
@@ -0,0 +1,218 @@
"use client";
import { useId, useLayoutEffect, useRef, type ReactNode, type RefObject } from "react";
import { Button } from "@kandev/ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
} from "@kandev/ui/popover";
export type ActionConfirmPopoverProps = {
open: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
title: ReactNode;
description?: ReactNode;
cancelLabel: ReactNode;
confirmLabel: ReactNode;
confirmAriaLabel?: string;
confirmTestId?: string;
testId?: string;
onOpenChange: (open: boolean) => void;
onCancel?: () => void;
onConfirm: () => void | Promise<void>;
};
/**
* A non-modal confirmation surface for one anchored action.
*
* The component owns only the confirmation shell. Consumers retain mutation,
* pending, error, and success state, and the shell closes before confirmation
* invokes the consumer callback.
*/
export function ActionConfirmPopover({
open,
anchorRef,
focusBoundaryRef,
title,
description,
cancelLabel,
confirmLabel,
confirmAriaLabel,
confirmTestId,
testId = "action-confirm-popover",
onOpenChange,
onCancel,
onConfirm,
}: ActionConfirmPopoverProps) {
const titleId = useId();
const descriptionId = useId();
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmedRef = useRef(false);
// Intentionally runs on every render: an anchor can disappear through live
// data without changing the confirmation's open state, so each render must
// re-check the guard before the shell can invoke a stale action.
useLayoutEffect(() => {
if (!open) {
confirmedRef.current = false;
return;
}
if (isConnected(anchorRef.current)) return;
onCancel?.();
onOpenChange(false);
});
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
confirmedRef.current = false;
onOpenChange(true);
return;
}
closeActionConfirm(confirmedRef, onCancel, onOpenChange);
};
const handleConfirm = () => {
if (!isConnected(anchorRef.current)) {
handleOpenChange(false);
return;
}
confirmedRef.current = true;
handleOpenChange(false);
queueMicrotask(() => {
void Promise.resolve()
.then(onConfirm)
.catch(() => undefined);
});
};
return (
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
{/* Radix accepts a null current value at runtime while its public type omits it. */}
<PopoverAnchor virtualRef={anchorRef as RefObject<HTMLElement>} />
<ActionConfirmPopoverContent
titleId={titleId}
descriptionId={descriptionId}
title={title}
description={description}
cancelLabel={cancelLabel}
confirmLabel={confirmLabel}
confirmAriaLabel={confirmAriaLabel}
confirmTestId={confirmTestId}
testId={testId}
cancelRef={cancelRef}
focusBoundaryRef={focusBoundaryRef}
confirmedRef={confirmedRef}
anchorRef={anchorRef}
onCancel={() => handleOpenChange(false)}
onConfirm={handleConfirm}
/>
</Popover>
);
}
type ActionConfirmPopoverContentProps = {
titleId: string;
descriptionId: string;
title: ReactNode;
description?: ReactNode;
cancelLabel: ReactNode;
confirmLabel: ReactNode;
confirmAriaLabel?: string;
confirmTestId?: string;
testId: string;
cancelRef: RefObject<HTMLButtonElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
confirmedRef: { current: boolean };
anchorRef: RefObject<HTMLElement | null>;
onCancel: () => void;
onConfirm: () => void;
};
function ActionConfirmPopoverContent({
titleId,
descriptionId,
title,
description,
cancelLabel,
confirmLabel,
confirmAriaLabel,
confirmTestId,
testId,
cancelRef,
focusBoundaryRef,
confirmedRef,
anchorRef,
onCancel,
onConfirm,
}: ActionConfirmPopoverContentProps) {
return (
<PopoverContent
role="dialog"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
data-testid={testId}
side="bottom"
align="end"
sideOffset={8}
className="w-64 gap-3 p-3"
onOpenAutoFocus={(event) => {
event.preventDefault();
cancelRef.current?.focus();
}}
onFocusOutside={(event) => {
if (focusBoundaryRef?.current?.contains(event.target as Node)) event.preventDefault();
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (!confirmedRef.current && isConnected(anchorRef.current)) anchorRef.current.focus();
confirmedRef.current = false;
}}
>
<PopoverHeader>
<PopoverTitle id={titleId}>{title}</PopoverTitle>
{description ? (
<PopoverDescription id={descriptionId}>{description}</PopoverDescription>
) : null}
</PopoverHeader>
<div className="flex justify-end gap-2">
<Button
ref={cancelRef}
type="button"
variant="outline"
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
onClick={onCancel}
>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
aria-label={confirmAriaLabel}
data-testid={confirmTestId}
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
onClick={onConfirm}
>
{confirmLabel}
</Button>
</div>
</PopoverContent>
);
}
function closeActionConfirm(
confirmedRef: { current: boolean },
onCancel: (() => void) | undefined,
onOpenChange: (open: boolean) => void,
) {
if (!confirmedRef.current) onCancel?.();
onOpenChange(false);
}
function isConnected(element: HTMLElement | null): element is HTMLElement {
return element !== null && element.isConnected;
}
@@ -0,0 +1,135 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { InlineConfirmActions } from "./inline-confirm-actions";
describe("InlineConfirmActions", () => {
afterEach(cleanup);
it("focuses Cancel first and gives touch actions a 44px active dimension", async () => {
render(
<InlineConfirmActions
density="touch"
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })),
);
expect(screen.getByRole("button", { name: "Delete" }).className).toContain("h-11");
expect(screen.getByRole("button", { name: "Delete" }).className).toContain("min-w-11");
});
it("cancels on Escape without invoking the destructive action", () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<InlineConfirmActions
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
fireEvent.keyDown(screen.getByRole("group"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it("keeps mutation ownership with the consumer", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
render(
<InlineConfirmActions
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
});
it("removes the shell before invoking the confirmation callback", async () => {
let shellClosed = false;
const onConfirm = vi.fn(() => {
shellClosed = screen.queryByRole("group") === null;
});
render(
<InlineConfirmActions
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(shellClosed).toBe(true);
});
it("closes through the consumer before the deferred callback", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
function Harness() {
const [open, setOpen] = useState(true);
if (!open) return null;
return (
<InlineConfirmActions
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={onConfirm}
onClose={() => setOpen(false)}
/>
);
}
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("group")).toBeNull();
});
});
describe("InlineConfirmActions rejected callbacks", () => {
afterEach(cleanup);
it("handles a rejected callback without an unhandled rejection", async () => {
const unhandled = vi.fn();
const onUnhandled = (event: PromiseRejectionEvent) => {
event.preventDefault();
unhandled(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandled);
const onConfirm = vi.fn(() => Promise.reject(new Error("delete failed")));
render(
<InlineConfirmActions
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={onConfirm}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.queryByRole("group")).not.toBeNull());
expect(unhandled).not.toHaveBeenCalled();
window.removeEventListener("unhandledrejection", onUnhandled);
});
});
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@kandev/ui/button";
export type InlineConfirmActionsProps = {
density?: "compact" | "touch";
testId?: string;
ariaLabel?: string;
description?: ReactNode;
cancelLabel: ReactNode;
confirmLabel: ReactNode;
confirmAriaLabel?: string;
confirmTestId?: string;
onCancel: () => void;
onClose?: () => void;
onConfirm: () => void | Promise<void>;
};
/**
* Inline confirmation actions for rows and menus, without owning mutation state.
*/
export function InlineConfirmActions({
density = "compact",
testId = "inline-confirm-actions",
ariaLabel,
description,
cancelLabel,
confirmLabel,
confirmAriaLabel,
confirmTestId,
onCancel,
onClose,
onConfirm,
}: InlineConfirmActionsProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const [confirmed, setConfirmed] = useState(false);
const touch = density === "touch";
const actionClass = touch ? "h-11 min-w-11 px-2" : "h-10 min-w-10 px-2 text-xs";
useEffect(() => {
cancelRef.current?.focus();
}, []);
const handleConfirm = () => {
onClose?.();
setConfirmed(true);
queueMicrotask(() => {
void Promise.resolve()
.then(onConfirm)
.catch(() => {
setConfirmed(false);
});
});
};
if (confirmed) return null;
return (
<div
role="group"
aria-label={ariaLabel}
data-testid={testId}
className={
description
? "flex w-full min-w-0 flex-col items-stretch gap-2"
: `flex shrink-0 items-center justify-end gap-1 ${touch ? "min-h-11" : "w-full min-h-10"}`
}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
onCancel();
}}
>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className="flex items-center justify-end gap-1">
<Button
ref={cancelRef}
type="button"
variant="ghost"
size="sm"
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
onClick={onCancel}
>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
aria-label={confirmAriaLabel}
data-testid={confirmTestId}
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
onClick={handleConfirm}
>
{confirmLabel}
</Button>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CloseTerminalConfirmPopover } from "./close-terminal-confirm-popover";
@@ -15,7 +15,7 @@ function deferredPromise() {
describe("CloseTerminalConfirmPopover", () => {
afterEach(cleanup);
it("uses a localized popover and dismisses before terminal teardown settles", () => {
it("uses a localized popover and dismisses before terminal teardown settles", async () => {
const teardown = deferredPromise();
const onConfirm = vi.fn(() => teardown.promise);
@@ -41,10 +41,11 @@ describe("CloseTerminalConfirmPopover", () => {
render(<Harness />);
expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBeTruthy();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(screen.getByRole("button", { name: "Close terminal" }).className).toContain("min-h-11");
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("dialog", { name: "Close terminal?" })).toBeNull();
});
});
@@ -1,17 +1,10 @@
"use client";
import { useId, useRef, type RefObject } from "react";
import { Button } from "@kandev/ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
} from "@kandev/ui/popover";
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
type CloseTerminalConfirmPopoverProps = {
open: boolean;
terminalName: string;
@@ -34,76 +27,19 @@ export function CloseTerminalConfirmPopover({
onConfirm,
}: CloseTerminalConfirmPopoverProps) {
const { t } = useTranslation();
const titleId = useId();
const descriptionId = useId();
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmedRef = useRef(false);
return (
<Popover
modal={false}
<ActionConfirmPopover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) confirmedRef.current = false;
onOpenChange(nextOpen);
}}
>
{/* Radix accepts a null current value at runtime while its public type omits it. */}
<PopoverAnchor virtualRef={anchorRef as RefObject<HTMLElement>} />
<PopoverContent
role="dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="terminal-close-confirm-popover"
side="bottom"
align="end"
sideOffset={8}
className="w-64 gap-3 p-3"
onOpenAutoFocus={(event) => {
event.preventDefault();
cancelRef.current?.focus();
}}
onFocusOutside={(event) => {
if (focusBoundaryRef?.current?.contains(event.target as Node)) {
event.preventDefault();
}
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (!confirmedRef.current) anchorRef.current?.focus();
confirmedRef.current = false;
}}
>
<PopoverHeader>
<PopoverTitle id={titleId}>{t("task:closeTerminal")}</PopoverTitle>
<PopoverDescription id={descriptionId}>
{t("task:thisStopsTheShellAndAny", { terminalName })}
</PopoverDescription>
</PopoverHeader>
<div className="flex justify-end gap-2">
<Button
ref={cancelRef}
type="button"
variant="outline"
className="h-10 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
onClick={() => onOpenChange(false)}
>
{t("common:cancel")}
</Button>
<Button
type="button"
variant="destructive"
className="h-10 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
onClick={() => {
confirmedRef.current = true;
onOpenChange(false);
void onConfirm();
}}
>
{t("task:closeTerminal2")}
</Button>
</div>
</PopoverContent>
</Popover>
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
title={t("task:closeTerminal")}
description={t("task:thisStopsTheShellAndAny", { terminalName })}
cancelLabel={t("common:cancel")}
confirmLabel={t("task:closeTerminal2")}
testId="terminal-close-confirm-popover"
onOpenChange={onOpenChange}
onConfirm={onConfirm}
/>
);
}
@@ -95,7 +95,7 @@ describe("mobile terminal close feedback", () => {
expect(screen.getByRole("group", { name: CLOSE_CONFIRMATION_NAME })).toBeTruthy();
});
it("always morphs the close action into inline cancel and confirm actions", () => {
it("always morphs the close action into inline cancel and confirm actions", async () => {
mockDestroyTerminal.mockImplementation(() => new Promise<boolean>(() => {}));
renderOpenPicker();
@@ -116,7 +116,7 @@ describe("mobile terminal close feedback", () => {
}),
);
expect(mockDestroyTerminal).toHaveBeenCalledWith(terminal.id);
await waitFor(() => expect(mockDestroyTerminal).toHaveBeenCalledWith(terminal.id));
expect(screen.queryByRole("group", { name: CLOSE_CONFIRMATION_NAME })).toBeNull();
});
@@ -15,7 +15,8 @@ type MobileTerminalRowProps = {
onSelect: (id: string) => void;
onAskClose: (terminal: Terminal) => void;
onCancelClose: () => void;
onConfirmClose: () => void;
onClose: () => void;
onConfirmClose: () => void | Promise<void>;
};
export function MobileTerminalRow({
@@ -26,6 +27,7 @@ export function MobileTerminalRow({
onSelect,
onAskClose,
onCancelClose,
onClose,
onConfirmClose,
}: MobileTerminalRowProps) {
const { t } = useTranslation();
@@ -55,6 +57,7 @@ export function MobileTerminalRow({
density="touch"
testId="mobile-terminal-close-confirmation"
onCancel={onCancelClose}
onClose={onClose}
onConfirm={onConfirmClose}
/>
) : (
@@ -40,11 +40,11 @@ function useTerminalCloseHandler({
[destroyTerminal, environmentId, terminals.length, addTerminal],
);
const handleConfirmClose = useCallback(() => {
const handleConfirmClose = useCallback(async () => {
if (!pendingClose) return;
const terminal = pendingClose;
setPendingClose(null);
void closeTerminal(terminal);
await closeTerminal(terminal);
}, [pendingClose, closeTerminal]);
return { pendingClose, setPendingClose, handleConfirmClose };
@@ -138,6 +138,7 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
onSelect={handleSelect}
onAskClose={handleAskClose}
onCancelClose={() => setPendingClose(null)}
onClose={() => setPendingClose(null)}
onConfirmClose={handleConfirmClose}
/>
))}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
@@ -17,7 +17,7 @@ const terminal: Terminal = {
describe("ParkedTerminalsMenu", () => {
afterEach(cleanup);
it("confirms a right-click destroy while preserving cancel", () => {
it("confirms a right-click destroy while preserving cancel", async () => {
const onResume = vi.fn();
const onDestroy = vi.fn();
@@ -47,7 +47,7 @@ describe("ParkedTerminalsMenu", () => {
}),
);
expect(onDestroy).toHaveBeenCalledOnce();
await waitFor(() => expect(onDestroy).toHaveBeenCalledOnce());
expect(onDestroy).toHaveBeenCalledWith(terminal.id);
expect(screen.queryByTestId("parked-terminals-menu")).toBeNull();
});
@@ -78,11 +78,11 @@ export function ParkedTerminalsMenu({
}}
onAskDestroy={() => setPendingDestroyId(terminal.id)}
onCancelDestroy={() => setPendingDestroyId(null)}
onConfirmDestroy={() => {
onCloseDestroy={() => {
setPendingDestroyId(null);
setOpen(false);
void onDestroy(terminal.id);
}}
onConfirmDestroy={() => onDestroy(terminal.id)}
/>
))}
</div>
@@ -97,6 +97,7 @@ function ParkedTerminalRow({
onResume,
onAskDestroy,
onCancelDestroy,
onCloseDestroy,
onConfirmDestroy,
}: {
terminal: Terminal;
@@ -104,7 +105,8 @@ function ParkedTerminalRow({
onResume: () => void;
onAskDestroy: () => void;
onCancelDestroy: () => void;
onConfirmDestroy: () => void;
onCloseDestroy: () => void;
onConfirmDestroy: () => void | Promise<void>;
}) {
if (isConfirming) {
return (
@@ -113,7 +115,11 @@ function ParkedTerminalRow({
data-testid={`parked-terminal-item-${terminal.id}`}
className="w-full px-1"
>
<TerminalCloseInlineConfirmation onCancel={onCancelDestroy} onConfirm={onConfirmDestroy} />
<TerminalCloseInlineConfirmation
onCancel={onCancelDestroy}
onClose={onCloseDestroy}
onConfirm={onConfirmDestroy}
/>
</div>
);
}
@@ -1,65 +1,36 @@
"use client";
import { useEffect, useRef } from "react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
type TerminalCloseInlineConfirmationProps = {
density?: "compact" | "touch";
testId?: string;
onCancel: () => void;
onConfirm: () => void;
onClose: () => void;
onConfirm: () => void | Promise<void>;
};
export function TerminalCloseInlineConfirmation({
density = "compact",
testId = "terminal-menu-close-confirmation",
onCancel,
onClose,
onConfirm,
}: TerminalCloseInlineConfirmationProps) {
const { t } = useTranslation();
const cancelRef = useRef<HTMLButtonElement>(null);
const touch = density === "touch";
const actionClass = touch ? "h-11 min-w-11 px-2" : "h-10 min-w-10 px-2 text-xs";
useEffect(() => {
cancelRef.current?.focus();
}, []);
return (
<div
role="group"
aria-label={t("task:closeTerminal")}
data-testid={testId}
className={`flex shrink-0 items-center justify-end gap-1 ${touch ? "min-h-11" : "w-full min-h-10"}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
onCancel();
}}
>
<Button
ref={cancelRef}
type="button"
variant="ghost"
size="sm"
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
onClick={onCancel}
>
{t("common:cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
onClick={onConfirm}
>
{t("task:closeTerminal2")}
</Button>
</div>
<InlineConfirmActions
density={density}
testId={testId}
ariaLabel={t("task:closeTerminal")}
cancelLabel={t("common:cancel")}
confirmLabel={t("task:closeTerminal2")}
onCancel={onCancel}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
+16 -8
View File
@@ -150,7 +150,7 @@ describe("TerminalTab", () => {
});
});
it("asks for localized confirmation before closing an idle terminal", () => {
it("asks for localized confirmation before closing an idle terminal", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
@@ -161,13 +161,15 @@ describe("TerminalTab", () => {
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1");
expect(mockClose).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1");
expect(mockClose).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
expect(screen.getByTestId("terminal-tab-shell-1").getAttribute("aria-busy")).toBeNull();
});
it("routes context-menu terminate through the same localized confirmation", () => {
it("routes context-menu terminate through the same localized confirmation", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
@@ -181,8 +183,10 @@ describe("TerminalTab", () => {
expect(screen.queryByRole("dialog", { name: "Close terminal?" })).toBeNull();
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
expect(mockClose).toHaveBeenCalledTimes(1);
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(mockClose).toHaveBeenCalledTimes(1);
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
});
});
it("focuses the inline rename input after the context menu closes", async () => {
@@ -202,7 +206,7 @@ describe("TerminalTab", () => {
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
expect(mockClose).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockClose).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
await waitFor(() => {
@@ -211,14 +215,18 @@ describe("TerminalTab", () => {
expect(mockToastError).toHaveBeenCalledWith("Failed to close terminal");
});
it("ignores a second confirmed terminate while close is in progress", () => {
it("ignores a second confirmed terminate while close is in progress", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await waitFor(() => expect(mockDestroyUserShell).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1");
});
@@ -0,0 +1,47 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
test.describe("Mobile workspace repository sets", () => {
test("confirms deletion inline without a second overlay", async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
const setName = `Mobile settings set ${Date.now()}`;
const created = await apiClient.createRepositorySet(seedData.workspaceId, setName, [
seedData.repositoryId,
]);
await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
const row = testPage.getByTestId("repository-set-row");
await expect(row).toContainText(setName);
await row.getByTestId(`repository-set-delete-${created.id}`).tap();
const inline = row.getByTestId("repository-set-delete-inline-confirmation");
await expect(inline).toBeVisible();
await expect(inline).toContainText(
"The set is removed. Its repositories, and any task already using them, are not affected.",
);
await expect(testPage.getByTestId("repository-set-delete-confirm-popover")).toHaveCount(0);
for (const control of [
inline.getByTestId("repository-set-delete-confirm"),
inline.getByRole("button", { name: "Cancel" }),
]) {
const box = await control.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await assertNoDocumentHorizontalOverflow(testPage, "mobile repository-set confirmation");
await inline.getByTestId("repository-set-delete-confirm").tap();
await expect(testPage.getByTestId("repository-sets-empty")).toBeVisible();
await expect
.poll(async () => {
const listed = await apiClient.listRepositorySets(seedData.workspaceId);
return listed.repository_sets.some((entry) => entry.id === created.id);
})
.toBe(false);
});
});
@@ -66,7 +66,9 @@ test.describe("Workspace repository sets settings", () => {
// Delete: the set goes, the repositories stay.
await testPage.getByTestId(`repository-set-delete-${createdId}`).click();
await expect(testPage.getByTestId("repository-set-delete-dialog")).toBeVisible();
const confirmation = testPage.getByTestId("repository-set-delete-confirm-popover");
await expect(confirmation).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
await testPage.getByTestId("repository-set-delete-confirm").click();
await expect(testPage.getByTestId("repository-sets-empty")).toBeVisible();