feat: add confirmation dialogs for workspace and session deletion
- Replace inline project/workspace delete confirmation with ConfirmDialog - Add ConfirmDialog before deleting active sessions from dashboard - Update ProjectsPage and DashboardPage tests for modal flow - Fix dashboard test mock paths (git-repositories, tool-types) Quality gates: typecheck, lint, build pass; targeted tests 12/12 pass
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HomePage } from "./DashboardPage";
|
||||
import * as sessionsApi from "../api/sessions";
|
||||
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
@@ -26,11 +27,11 @@ vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/git_repositories", () => ({
|
||||
vi.mock("../api/git-repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool_types", () => ({
|
||||
vi.mock("../api/tool-types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
}));
|
||||
|
||||
@@ -78,4 +79,54 @@ describe("HomePage", () => {
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||
});
|
||||
|
||||
it("shows delete confirmation dialog before deleting an active session", async () => {
|
||||
const activeSession = {
|
||||
id: "session-1",
|
||||
project_id: "proj-1",
|
||||
repository_id: "repo-1",
|
||||
display_name: "My Session",
|
||||
tool_type_name: "Editor",
|
||||
project_name: "Alpha",
|
||||
repository_name: "alpha-repo",
|
||||
status: "running",
|
||||
url: "http://localhost:8001",
|
||||
tool_type_interfaces: ["web"],
|
||||
};
|
||||
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([activeSession]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
|
||||
const deleteMock = vi
|
||||
.spyOn(sessionsApi, "deleteInstance")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sessionCard = screen.getByText("My Session").closest(".session-card") as HTMLElement | null;
|
||||
if (!sessionCard) throw new Error("Session card not found");
|
||||
|
||||
fireEvent.click(within(sessionCard).getByRole("button", { name: /delete/i }));
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/delete session/i)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/are you sure/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("proj-1", "repo-1", "session-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
QuickCreateForm,
|
||||
RecentSessionsSection,
|
||||
} from "../components/features/dashboard";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { ConfirmDialog, LoadingState, ErrorState } from "../components/ui";
|
||||
import { useDashboardActions } from "../hooks/use-dashboard-actions";
|
||||
import { listRepositories } from "../api/git-repositories";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
@@ -31,6 +31,9 @@ export const HomePage = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<SessionView | null>(
|
||||
null,
|
||||
);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
@@ -82,6 +85,20 @@ export const HomePage = () => {
|
||||
|
||||
const actions = useDashboardActions(loadHome);
|
||||
|
||||
const handleDeleteRequest = useCallback((session: SessionView) => {
|
||||
setSessionToDelete(session);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!sessionToDelete) return;
|
||||
await actions.handleDelete(sessionToDelete);
|
||||
setSessionToDelete(null);
|
||||
}, [sessionToDelete, actions]);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setSessionToDelete(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
@@ -138,7 +155,7 @@ export const HomePage = () => {
|
||||
actionBusy={actions.actionBusy}
|
||||
onOpen={actions.handleOpen}
|
||||
onStop={actions.handleStop}
|
||||
onDelete={actions.handleDelete}
|
||||
onDelete={handleDeleteRequest}
|
||||
onRecreateTunnel={actions.handleRecreateTunnel}
|
||||
/>
|
||||
</section>
|
||||
@@ -186,6 +203,20 @@ export const HomePage = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={sessionToDelete !== null}
|
||||
title="Delete session?"
|
||||
message={
|
||||
sessionToDelete
|
||||
? `Are you sure you want to delete "${sessionToDelete.display_name || sessionToDelete.tool_type_name || "this session"}"? This action cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("ProjectsPage", () => {
|
||||
expect(lastAction).toBe(workspaceLink);
|
||||
});
|
||||
|
||||
it("shows delete confirmation and deletes project", async () => {
|
||||
it("shows delete confirmation dialog and deletes project", async () => {
|
||||
const listMock = vi
|
||||
.spyOn(projectsApi, "listProjects")
|
||||
.mockResolvedValue(mockProjects);
|
||||
@@ -193,9 +193,13 @@ describe("ProjectsPage", () => {
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/delete workspace/i)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/are you sure/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("proj-1");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ProjectCreateInput,
|
||||
} from "../api/projects";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { ConfirmDialog } from "../components/ui";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
@@ -20,7 +21,7 @@ export const ProjectsPage = () => {
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -72,13 +73,14 @@ export const ProjectsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
const handleDelete = async () => {
|
||||
if (!projectToDelete) return;
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
await deleteProject(projectToDelete.id);
|
||||
setProjectToDelete(null);
|
||||
await loadProjects();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
setProjectToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,36 +126,14 @@ export const ProjectsPage = () => {
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setProjectToDelete(project)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
@@ -163,6 +143,20 @@ export const ProjectsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={projectToDelete !== null}
|
||||
title="Delete workspace?"
|
||||
message={
|
||||
projectToDelete
|
||||
? `Are you sure you want to delete "${projectToDelete.name}"? This will remove the project and its repositories. This action cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setProjectToDelete(null)}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
|
||||
Reference in New Issue
Block a user