From b21456093eb1c61f8abf2363977a8efd0af5ed07 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 15 Jun 2026 11:50:09 +0000 Subject: [PATCH] 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 --- apps/web/src/pages/DashboardPage.test.tsx | 57 ++++++++++++++++++-- apps/web/src/pages/DashboardPage.tsx | 35 ++++++++++++- apps/web/src/pages/ProjectsPage.test.tsx | 10 ++-- apps/web/src/pages/ProjectsPage.tsx | 64 ++++++++++------------- 4 files changed, 123 insertions(+), 43 deletions(-) diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx index 7266162..f648980 100644 --- a/apps/web/src/pages/DashboardPage.test.tsx +++ b/apps/web/src/pages/DashboardPage.test.tsx @@ -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( + + + + ); + + 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"); + }); + }); }); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index bc2effe..a301b6c 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -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([]); const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); + const [sessionToDelete, setSessionToDelete] = useState( + 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 (
@@ -138,7 +155,7 @@ export const HomePage = () => { actionBusy={actions.actionBusy} onOpen={actions.handleOpen} onStop={actions.handleStop} - onDelete={actions.handleDelete} + onDelete={handleDeleteRequest} onRecreateTunnel={actions.handleRecreateTunnel} />
@@ -186,6 +203,20 @@ export const HomePage = () => { /> )} + + ); }; diff --git a/apps/web/src/pages/ProjectsPage.test.tsx b/apps/web/src/pages/ProjectsPage.test.tsx index 6ee5c60..ebcdaa9 100644 --- a/apps/web/src/pages/ProjectsPage.test.tsx +++ b/apps/web/src/pages/ProjectsPage.test.tsx @@ -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"); diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx index 5c965f7..add0680 100644 --- a/apps/web/src/pages/ProjectsPage.tsx +++ b/apps/web/src/pages/ProjectsPage.tsx @@ -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(null); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [projectToDelete, setProjectToDelete] = useState(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 = () => { Settings - {deleteConfirmId === project.id ? ( -
- Are you sure? - - -
- ) : ( - - )} + Open Workspace @@ -163,6 +143,20 @@ export const ProjectsPage = () => { )} + void handleDelete()} + onCancel={() => setProjectToDelete(null)} + /> + {showCreate && (