From 34b17ab77480ccf52ca418893b57ab6ca494cc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:24:06 +0100 Subject: [PATCH] feat(ui): add localized action confirmations (#2818) --- .../use-workspace-repository-sets.ts | 5 +- .../workspace-repository-set-delete.tsx | 83 +++---- ...workspace-repository-sets-section.test.tsx | 101 +++++++- .../workspace-repository-sets-section.tsx | 175 +++++++++++--- .../action-confirm-popover.test.tsx | 213 +++++++++++++++++ .../confirmation/action-confirm-popover.tsx | 218 ++++++++++++++++++ .../inline-confirm-actions.test.tsx | 135 +++++++++++ .../confirmation/inline-confirm-actions.tsx | 104 +++++++++ .../close-terminal-confirm-popover.test.tsx | 7 +- .../task/close-terminal-confirm-popover.tsx | 92 ++------ .../task/mobile/mobile-terminal-row.test.tsx | 4 +- .../task/mobile/mobile-terminal-row.tsx | 5 +- .../task/mobile/mobile-terminals-section.tsx | 5 +- .../task/parked-terminals-menu.test.tsx | 6 +- .../components/task/parked-terminals-menu.tsx | 14 +- .../terminal-close-inline-confirmation.tsx | 59 ++--- .../web/components/task/terminal-tab.test.tsx | 24 +- .../mobile-workspace-repository-sets.spec.ts | 47 ++++ .../workspace-repository-sets.spec.ts | 4 +- 19 files changed, 1063 insertions(+), 238 deletions(-) create mode 100644 apps/web/components/confirmation/action-confirm-popover.test.tsx create mode 100644 apps/web/components/confirmation/action-confirm-popover.tsx create mode 100644 apps/web/components/confirmation/inline-confirm-actions.test.tsx create mode 100644 apps/web/components/confirmation/inline-confirm-actions.tsx create mode 100644 apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts diff --git a/apps/web/app/settings/workspace/use-workspace-repository-sets.ts b/apps/web/app/settings/workspace/use-workspace-repository-sets.ts index 197d24910d..f9f2bdaeb6 100644 --- a/apps/web/app/settings/workspace/use-workspace-repository-sets.ts +++ b/apps/web/app/settings/workspace/use-workspace-repository-sets.ts @@ -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 () => { diff --git a/apps/web/app/settings/workspace/workspace-repository-set-delete.tsx b/apps/web/app/settings/workspace/workspace-repository-set-delete.tsx index 6e1a2c7b12..e5f2c7dc35 100644 --- a/apps/web/app/settings/workspace/workspace-repository-set-delete.tsx +++ b/apps/web/app/settings/workspace/workspace-repository-set-delete.tsx @@ -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; + onOpenChange: (open: boolean) => void; + onCancel: () => void; + onConfirm: () => void | Promise; }; /** - * 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 ( - (next ? undefined : onClose())}> - - - - {t("workspaces:repositorySetsDeleteTitle", { name: set.name })} - - - {t("workspaces:repositorySetsDeleteDescription")} - - - {error ? ( -

- {error} -

- ) : null} - - {t("common:cancel")} - - {t("workspaces:repositorySetsDelete")} - - -
-
+ ); } diff --git a/apps/web/app/settings/workspace/workspace-repository-sets-section.test.tsx b/apps/web/app/settings/workspace/workspace-repository-sets-section.test.tsx index 6e967ce5c7..5ecb0bf4b4 100644 --- a/apps/web/app/settings/workspace/workspace-repository-sets-section.test.tsx +++ b/apps/web/app/settings/workspace/workspace-repository-sets-section.test.tsx @@ -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); }); diff --git a/apps/web/app/settings/workspace/workspace-repository-sets-section.tsx b/apps/web/app/settings/workspace/workspace-repository-sets-section.tsx index 52ecb73cf8..4586fc4047 100644 --- a/apps/web/app/settings/workspace/workspace-repository-sets-section.tsx +++ b/apps/web/app/settings/workspace/workspace-repository-sets-section.tsx @@ -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(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 ( manager.startEdit(set)} - onDelete={() => manager.startDelete(set)} + onDelete={() => startDelete(set)} + onDeleteCancel={cancelDelete} + onDeleteOpenChange={closeDeleteConfirmation} + onDeleteConfirm={confirmDelete} /> ))} @@ -81,12 +117,6 @@ export function WorkspaceRepositorySetsSection({ onChange={manager.updateDraft} onSubmit={manager.submitDraft} /> - ); } @@ -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(null); const namesById = new Map( repositories.map((repository) => [repository.id as string, repository]), ); @@ -129,31 +172,36 @@ function RepositorySetRow({

{set.description}

) : null} - {readOnly ? null : ( -
- - -
- )} + + {!isFinePointer && confirmingDelete ? ( + onDeleteOpenChange(false)} + onConfirm={onDeleteConfirm} + /> + ) : null} + {deleteError ? ( +

+ {deleteError} +

+ ) : null}
{set.repositories.length === 0 ? ( @@ -167,6 +215,67 @@ function RepositorySetRow({ )) )}
+ {isFinePointer ? ( + + ) : null} + + ); +} + +type RepositorySetRowActionsProps = { + set: RepositorySet; + readOnly: boolean; + isFinePointer: boolean; + confirmingDelete: boolean; + deleteAnchorRef: RefObject; + 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 ( +
+ +
); } diff --git a/apps/web/components/confirmation/action-confirm-popover.test.tsx b/apps/web/components/confirmation/action-confirm-popover.test.tsx new file mode 100644 index 0000000000..8bae9f5f36 --- /dev/null +++ b/apps/web/components/confirmation/action-confirm-popover.test.tsx @@ -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 }) { + const [open, setOpen] = useState(true); + const [showAnchor, setShowAnchor] = useState(true); + const anchorRef = useRef(null); + return ( + <> + {showAnchor ? ( + + ) : null} + + + + ); +} + +describe("ActionConfirmPopover", () => { + afterEach(cleanup); + + it("focuses Cancel first and gives coarse pointers a touch-sized action", async () => { + render(); + + 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(() => {})); + render(); + + 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(); + + 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(); + + 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(null); + const onConfirm = vi.fn(); + return ( + <> + {showAnchor ? ( + + ) : null} + + + ); + } + + render(); + 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(null); + const menuRef = useRef(null); + return ( + <> + +
+ Menu +
+ + + ); + } + + render(); + 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(null); + const menuRef = useRef(null); + return ( + <> + +
+ Menu +
+ + + + ); + } + + render(); + screen.getByTestId("outside").focus(); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: deleteTitle })).toBeNull()); + }); +}); diff --git a/apps/web/components/confirmation/action-confirm-popover.tsx b/apps/web/components/confirmation/action-confirm-popover.tsx new file mode 100644 index 0000000000..bc81f71771 --- /dev/null +++ b/apps/web/components/confirmation/action-confirm-popover.tsx @@ -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; + focusBoundaryRef?: RefObject; + title: ReactNode; + description?: ReactNode; + cancelLabel: ReactNode; + confirmLabel: ReactNode; + confirmAriaLabel?: string; + confirmTestId?: string; + testId?: string; + onOpenChange: (open: boolean) => void; + onCancel?: () => void; + onConfirm: () => void | Promise; +}; + +/** + * 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(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 ( + + {/* Radix accepts a null current value at runtime while its public type omits it. */} + } /> + handleOpenChange(false)} + onConfirm={handleConfirm} + /> + + ); +} + +type ActionConfirmPopoverContentProps = { + titleId: string; + descriptionId: string; + title: ReactNode; + description?: ReactNode; + cancelLabel: ReactNode; + confirmLabel: ReactNode; + confirmAriaLabel?: string; + confirmTestId?: string; + testId: string; + cancelRef: RefObject; + focusBoundaryRef?: RefObject; + confirmedRef: { current: boolean }; + anchorRef: RefObject; + onCancel: () => void; + onConfirm: () => void; +}; + +function ActionConfirmPopoverContent({ + titleId, + descriptionId, + title, + description, + cancelLabel, + confirmLabel, + confirmAriaLabel, + confirmTestId, + testId, + cancelRef, + focusBoundaryRef, + confirmedRef, + anchorRef, + onCancel, + onConfirm, +}: ActionConfirmPopoverContentProps) { + return ( + { + 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; + }} + > + + {title} + {description ? ( + {description} + ) : null} + +
+ + +
+
+ ); +} + +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; +} diff --git a/apps/web/components/confirmation/inline-confirm-actions.test.tsx b/apps/web/components/confirmation/inline-confirm-actions.test.tsx new file mode 100644 index 0000000000..d48f409c66 --- /dev/null +++ b/apps/web/components/confirmation/inline-confirm-actions.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(() => {})); + render( + , + ); + + 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( + , + ); + + 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(() => {})); + + function Harness() { + const [open, setOpen] = useState(true); + if (!open) return null; + return ( + setOpen(false)} + /> + ); + } + + render(); + 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( + , + ); + 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); + }); +}); diff --git a/apps/web/components/confirmation/inline-confirm-actions.tsx b/apps/web/components/confirmation/inline-confirm-actions.tsx new file mode 100644 index 0000000000..6b3d11e3ff --- /dev/null +++ b/apps/web/components/confirmation/inline-confirm-actions.tsx @@ -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; +}; + +/** + * 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(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 ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onCancel(); + }} + > + {description ?

{description}

: null} +
+ + +
+
+ ); +} diff --git a/apps/web/components/task/close-terminal-confirm-popover.test.tsx b/apps/web/components/task/close-terminal-confirm-popover.test.tsx index c5dcb227a8..aba83504fb 100644 --- a/apps/web/components/task/close-terminal-confirm-popover.test.tsx +++ b/apps/web/components/task/close-terminal-confirm-popover.test.tsx @@ -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(); 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(); }); }); diff --git a/apps/web/components/task/close-terminal-confirm-popover.tsx b/apps/web/components/task/close-terminal-confirm-popover.tsx index b33b6858d6..f8f15a37f8 100644 --- a/apps/web/components/task/close-terminal-confirm-popover.tsx +++ b/apps/web/components/task/close-terminal-confirm-popover.tsx @@ -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(null); - const confirmedRef = useRef(false); return ( - { - if (nextOpen) confirmedRef.current = false; - onOpenChange(nextOpen); - }} - > - {/* Radix accepts a null current value at runtime while its public type omits it. */} - } /> - { - 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; - }} - > - - {t("task:closeTerminal")} - - {t("task:thisStopsTheShellAndAny", { terminalName })} - - -
- - -
-
-
+ 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} + /> ); } diff --git a/apps/web/components/task/mobile/mobile-terminal-row.test.tsx b/apps/web/components/task/mobile/mobile-terminal-row.test.tsx index a0755d845b..d6afae789a 100644 --- a/apps/web/components/task/mobile/mobile-terminal-row.test.tsx +++ b/apps/web/components/task/mobile/mobile-terminal-row.test.tsx @@ -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(() => {})); 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(); }); diff --git a/apps/web/components/task/mobile/mobile-terminal-row.tsx b/apps/web/components/task/mobile/mobile-terminal-row.tsx index 6598fea228..bfc36920ea 100644 --- a/apps/web/components/task/mobile/mobile-terminal-row.tsx +++ b/apps/web/components/task/mobile/mobile-terminal-row.tsx @@ -15,7 +15,8 @@ type MobileTerminalRowProps = { onSelect: (id: string) => void; onAskClose: (terminal: Terminal) => void; onCancelClose: () => void; - onConfirmClose: () => void; + onClose: () => void; + onConfirmClose: () => void | Promise; }; 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} /> ) : ( diff --git a/apps/web/components/task/mobile/mobile-terminals-section.tsx b/apps/web/components/task/mobile/mobile-terminals-section.tsx index 7206c3c679..82096534eb 100644 --- a/apps/web/components/task/mobile/mobile-terminals-section.tsx +++ b/apps/web/components/task/mobile/mobile-terminals-section.tsx @@ -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} /> ))} diff --git a/apps/web/components/task/parked-terminals-menu.test.tsx b/apps/web/components/task/parked-terminals-menu.test.tsx index 7d5cfd32ba..95514d6ed7 100644 --- a/apps/web/components/task/parked-terminals-menu.test.tsx +++ b/apps/web/components/task/parked-terminals-menu.test.tsx @@ -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(); }); diff --git a/apps/web/components/task/parked-terminals-menu.tsx b/apps/web/components/task/parked-terminals-menu.tsx index 53bb1b9100..4f7d77d951 100644 --- a/apps/web/components/task/parked-terminals-menu.tsx +++ b/apps/web/components/task/parked-terminals-menu.tsx @@ -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)} /> ))} @@ -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; }) { if (isConfirming) { return ( @@ -113,7 +115,11 @@ function ParkedTerminalRow({ data-testid={`parked-terminal-item-${terminal.id}`} className="w-full px-1" > - + ); } diff --git a/apps/web/components/task/terminal-close-inline-confirmation.tsx b/apps/web/components/task/terminal-close-inline-confirmation.tsx index c66fd54e17..e45b814859 100644 --- a/apps/web/components/task/terminal-close-inline-confirmation.tsx +++ b/apps/web/components/task/terminal-close-inline-confirmation.tsx @@ -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; }; export function TerminalCloseInlineConfirmation({ density = "compact", testId = "terminal-menu-close-confirmation", onCancel, + onClose, onConfirm, }: TerminalCloseInlineConfirmationProps) { const { t } = useTranslation(); - const cancelRef = useRef(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 ( -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - onCancel(); - }} - > - - -
+ ); } diff --git a/apps/web/components/task/terminal-tab.test.tsx b/apps/web/components/task/terminal-tab.test.tsx index 47b914162e..3008686f72 100644 --- a/apps/web/components/task/terminal-tab.test.tsx +++ b/apps/web/components/task/terminal-tab.test.tsx @@ -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(); 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(); 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(); 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((resolve) => queueMicrotask(resolve)); expect(mockDestroyUserShell).toHaveBeenCalledTimes(1); expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1"); }); diff --git a/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts b/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts new file mode 100644 index 0000000000..d511c95767 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts b/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts index 80317cabef..86b3c5a7a1 100644 --- a/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts +++ b/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts @@ -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();