feat(ui): add localized action confirmations (#2818)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user