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:
Developer
2026-06-15 11:50:09 +00:00
parent 5ed5e1c84b
commit b21456093e
4 changed files with 123 additions and 43 deletions
+54 -3
View File
@@ -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");
});
});
});
+33 -2
View File
@@ -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>
);
};
+7 -3
View File
@@ -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");
+29 -35
View File
@@ -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">