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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./DashboardPage"; import { HomePage } from "./DashboardPage";
import * as sessionsApi from "../api/sessions";
const mockDashboard = vi.fn(); const mockDashboard = vi.fn();
const mockSessions = vi.fn(); const mockSessions = vi.fn();
@@ -26,11 +27,11 @@ vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args) listProjects: (...args: unknown[]) => mockProjects(...args)
})); }));
vi.mock("../api/git_repositories", () => ({ vi.mock("../api/git-repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args) listRepositories: (...args: unknown[]) => mockRepos(...args)
})); }));
vi.mock("../api/tool_types", () => ({ vi.mock("../api/tool-types", () => ({
listToolTypes: vi.fn().mockResolvedValue([]) listToolTypes: vi.fn().mockResolvedValue([])
})); }));
@@ -78,4 +79,54 @@ describe("HomePage", () => {
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]); 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, QuickCreateForm,
RecentSessionsSection, RecentSessionsSection,
} from "../components/features/dashboard"; } 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 { useDashboardActions } from "../hooks/use-dashboard-actions";
import { listRepositories } from "../api/git-repositories"; import { listRepositories } from "../api/git-repositories";
import type { GitRepository } from "../types/git-repository"; import type { GitRepository } from "../types/git-repository";
@@ -31,6 +31,9 @@ export const HomePage = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [sessionToDelete, setSessionToDelete] = useState<SessionView | null>(
null,
);
const safeSessions = Array.isArray(sessions) ? sessions : []; const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => { const loadHome = useCallback(async () => {
@@ -82,6 +85,20 @@ export const HomePage = () => {
const actions = useDashboardActions(loadHome); 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 ( return (
<section className="stack home-page"> <section className="stack home-page">
<header className="home-hero card"> <header className="home-hero card">
@@ -138,7 +155,7 @@ export const HomePage = () => {
actionBusy={actions.actionBusy} actionBusy={actions.actionBusy}
onOpen={actions.handleOpen} onOpen={actions.handleOpen}
onStop={actions.handleStop} onStop={actions.handleStop}
onDelete={actions.handleDelete} onDelete={handleDeleteRequest}
onRecreateTunnel={actions.handleRecreateTunnel} onRecreateTunnel={actions.handleRecreateTunnel}
/> />
</section> </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> </section>
); );
}; };
+7 -3
View File
@@ -173,7 +173,7 @@ describe("ProjectsPage", () => {
expect(lastAction).toBe(workspaceLink); expect(lastAction).toBe(workspaceLink);
}); });
it("shows delete confirmation and deletes project", async () => { it("shows delete confirmation dialog and deletes project", async () => {
const listMock = vi const listMock = vi
.spyOn(projectsApi, "listProjects") .spyOn(projectsApi, "listProjects")
.mockResolvedValue(mockProjects); .mockResolvedValue(mockProjects);
@@ -193,9 +193,13 @@ describe("ProjectsPage", () => {
if (!alphaCard) throw new Error("Card not found"); if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i })); 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(() => { await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1"); expect(deleteMock).toHaveBeenCalledWith("proj-1");
+29 -35
View File
@@ -9,6 +9,7 @@ import {
type ProjectCreateInput, type ProjectCreateInput,
} from "../api/projects"; } from "../api/projects";
import { Icon } from "../components/ui/Icon"; import { Icon } from "../components/ui/Icon";
import { ConfirmDialog } from "../components/ui";
import type { Project } from "../types"; import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error"; type ProjectsStatus = "loading" | "ready" | "error";
@@ -20,7 +21,7 @@ export const ProjectsPage = () => {
const [formName, setFormName] = useState(""); const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState(""); const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null); 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 () => { const loadProjects = useCallback(async () => {
setStatus("loading"); setStatus("loading");
@@ -72,13 +73,14 @@ export const ProjectsPage = () => {
} }
}; };
const handleDelete = async (projectId: string) => { const handleDelete = async () => {
if (!projectToDelete) return;
try { try {
await deleteProject(projectId); await deleteProject(projectToDelete.id);
setDeleteConfirmId(null); setProjectToDelete(null);
await loadProjects(); await loadProjects();
} catch { } catch {
setDeleteConfirmId(null); setProjectToDelete(null);
} }
}; };
@@ -124,36 +126,14 @@ export const ProjectsPage = () => {
<Icon name="settings" size="sm" /> <Icon name="settings" size="sm" />
Settings Settings
</Link> </Link>
{deleteConfirmId === project.id ? ( <button
<div className="delete-confirm"> className="ghost-button danger-text"
<span>Are you sure?</span> onClick={() => setProjectToDelete(project)}
<button type="button"
className="danger-button" >
onClick={() => void handleDelete(project.id)} <Icon name="delete" size="sm" />
type="button" Delete
> </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>
)}
<Link className="ghost-button" to={`/projects/${project.id}`}> <Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace Open Workspace
</Link> </Link>
@@ -163,6 +143,20 @@ export const ProjectsPage = () => {
</div> </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 && ( {showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">