Skip to content

Commit 571623e

Browse files
Merge pull request #105 from utpalsinghdev/utpalsinghdev/workspace-delete-error
fix: surface workspace deletion errors
2 parents c096177 + be9d6d9 commit 571623e

4 files changed

Lines changed: 245 additions & 4 deletions

File tree

‎packages/web/src/api/queries.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@ import { QK } from "./keys";
44

55
// ─── Helpers ──────────────────────────────────────────────────────────────────
66

7+
function formatApiError(e: unknown): string {
8+
if (typeof e === "object" && e !== null && "detail" in e && typeof e.detail === "string") {
9+
const detail = e.detail.trim();
10+
if (detail) return detail;
11+
}
12+
if (typeof e === "object") return JSON.stringify(e);
13+
return String(e);
14+
}
15+
716
function err(e: unknown): never {
8-
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
17+
throw new Error(formatApiError(e));
918
}
1019

1120
// ─── Workspaces ──────────────────────────────────────────────────────────────

‎packages/web/src/components/shared/ConfirmDialog.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
1818
onCancel: () => void;
1919
danger?: boolean;
2020
loading?: boolean;
21+
error?: string;
2122
}
2223

2324
export function ConfirmDialog({
@@ -29,6 +30,7 @@ export function ConfirmDialog({
2930
onCancel,
3031
danger = true,
3132
loading = false,
33+
error,
3234
}: ConfirmDialogProps) {
3335
return (
3436
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
@@ -51,6 +53,11 @@ export function ConfirmDialog({
5153
<DialogDescription className="mt-1">{description}</DialogDescription>
5254
</div>
5355
</div>
56+
{error ? (
57+
<p role="alert" className="text-sm mb-1" style={{ color: COLOR.destructive }}>
58+
{error}
59+
</p>
60+
) : null}
5461
<DialogFooter>
5562
<Button variant="surface" size="sm" onClick={onCancel}>
5663
Cancel

‎packages/web/src/components/workspaces/WorkspaceDetail.tsx‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const NAV_SECTIONS = [
5959
},
6060
] as const;
6161

62+
const WORKSPACE_DELETE_FAILED = "Could not delete workspace. Try again.";
63+
6264
export function WorkspaceDetail() {
6365
const { mask } = useDemo();
6466
const { showMetadata } = useMetadata();
@@ -76,10 +78,24 @@ export function WorkspaceDetail() {
7678
const [sessionsExpanded, setSessionsExpanded] = useState(false);
7779

7880
const handleDelete = async () => {
79-
await deleteWorkspace.mutateAsync(workspaceId);
81+
try {
82+
await deleteWorkspace.mutateAsync(workspaceId);
83+
} catch {
84+
return;
85+
}
8086
navigate({ to: "/workspaces" as never });
8187
};
8288

89+
const openDelete = () => {
90+
deleteWorkspace.reset();
91+
setConfirmDelete(true);
92+
};
93+
94+
const cancelDelete = () => {
95+
deleteWorkspace.reset();
96+
setConfirmDelete(false);
97+
};
98+
8399
return (
84100
<div className="page-container page-container--wide">
85101
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
@@ -98,7 +114,7 @@ export function WorkspaceDetail() {
98114
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
99115
Schedule Dream
100116
</Button>
101-
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
117+
<Button variant="destructive" size="sm" onClick={openDelete}>
102118
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
103119
Delete
104120
</Button>
@@ -344,8 +360,13 @@ export function WorkspaceDetail() {
344360
description={`This will permanently delete workspace "${mask(workspaceId)}" and all its data. This cannot be undone.`}
345361
confirmLabel="Delete workspace"
346362
onConfirm={handleDelete}
347-
onCancel={() => setConfirmDelete(false)}
363+
onCancel={cancelDelete}
348364
loading={deleteWorkspace.isPending}
365+
error={
366+
deleteWorkspace.isError
367+
? deleteWorkspace.error?.message || WORKSPACE_DELETE_FAILED
368+
: undefined
369+
}
349370
/>
350371

351372
<ScheduleDreamModal
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
3+
import { render, screen, waitFor, within } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
7+
import { DemoProvider } from "@/context/DemoContext";
8+
import { MetadataProvider } from "@/context/MetadataContext";
9+
import { saveStore } from "@/lib/config";
10+
import { routeTree } from "@/routeTree.gen";
11+
12+
const { httpFetch } = vi.hoisted(() => ({ httpFetch: vi.fn() }));
13+
vi.mock("@/lib/http", () => ({ httpFetch }));
14+
15+
const WORKSPACE_ID = "ws-alpha";
16+
const CONFLICT_DETAIL =
17+
"Cannot delete workspace 'ws-alpha': active session(s) remain. Delete all sessions first.";
18+
const INSTANCE = {
19+
id: "inst-1",
20+
name: "Local",
21+
baseUrl: "http://localhost:8000",
22+
token: "secret-token",
23+
};
24+
25+
function json(body: unknown, status = 200) {
26+
return new Response(JSON.stringify(body), {
27+
status,
28+
headers: { "Content-Type": "application/json" },
29+
});
30+
}
31+
32+
function requestOf(input: Request | string, init?: RequestInit) {
33+
return typeof input === "string" ? new Request(input, init) : input;
34+
}
35+
36+
function mockHoncho(options: { onDelete?: (req: Request) => Promise<Response> | Response } = {}) {
37+
httpFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => {
38+
const req = requestOf(input, init);
39+
const url = req.url;
40+
if (req.method === "DELETE") {
41+
return options.onDelete ? options.onDelete(req) : json({}, 202);
42+
}
43+
if (url.includes("/queue/status")) {
44+
return json({
45+
in_progress_work_units: 0,
46+
pending_work_units: 0,
47+
completed_work_units: 0,
48+
total_work_units: 0,
49+
});
50+
}
51+
if (url.includes("/v3/workspaces") && !url.includes("list")) {
52+
return json({
53+
id: WORKSPACE_ID,
54+
metadata: {},
55+
created_at: "2026-01-01T00:00:00Z",
56+
});
57+
}
58+
return json({
59+
items: [{ id: WORKSPACE_ID, created_at: "2026-01-01T00:00:00Z" }],
60+
total: 1,
61+
page: 1,
62+
size: 20,
63+
pages: 1,
64+
});
65+
});
66+
}
67+
68+
function renderWorkspace() {
69+
saveStore({ instances: [INSTANCE], activeId: INSTANCE.id });
70+
const router = createRouter({
71+
routeTree,
72+
history: createMemoryHistory({ initialEntries: [`/workspaces/${WORKSPACE_ID}`] }),
73+
});
74+
const qc = new QueryClient({
75+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
76+
});
77+
return {
78+
router,
79+
...render(
80+
<QueryClientProvider client={qc}>
81+
<DemoProvider>
82+
<MetadataProvider>
83+
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
84+
<RouterProvider router={router as any} />
85+
</MetadataProvider>
86+
</DemoProvider>
87+
</QueryClientProvider>,
88+
),
89+
};
90+
}
91+
92+
async function confirmDelete() {
93+
const user = userEvent.setup();
94+
await user.click(await screen.findByRole("button", { name: "Delete" }));
95+
const dialog = await screen.findByRole("dialog");
96+
await user.click(within(dialog).getByRole("button", { name: "Delete workspace" }));
97+
return { user, dialog };
98+
}
99+
100+
describe("workspace delete errors", () => {
101+
afterEach(() => {
102+
httpFetch.mockReset();
103+
localStorage.clear();
104+
});
105+
106+
it("shows the 409 detail after confirming deletion", async () => {
107+
mockHoncho({
108+
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
109+
});
110+
renderWorkspace();
111+
await confirmDelete();
112+
expect((await screen.findByRole("alert")).textContent).toBe(CONFLICT_DETAIL);
113+
});
114+
115+
it("keeps the confirmation dialog open after a 409", async () => {
116+
mockHoncho({
117+
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
118+
});
119+
renderWorkspace();
120+
await confirmDelete();
121+
await screen.findByRole("alert");
122+
expect(screen.getByRole("dialog")).toHaveAccessibleName("Delete workspace");
123+
});
124+
125+
it("leaves the confirm button usable after a failed deletion", async () => {
126+
mockHoncho({
127+
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
128+
});
129+
renderWorkspace();
130+
const { dialog } = await confirmDelete();
131+
await screen.findByRole("alert");
132+
expect(within(dialog).getByRole("button", { name: "Delete workspace" })).toBeEnabled();
133+
});
134+
135+
it("retries deletion from the open dialog after a 409", async () => {
136+
let deletes = 0;
137+
mockHoncho({
138+
onDelete: () => {
139+
deletes += 1;
140+
return json({ detail: CONFLICT_DETAIL }, 409);
141+
},
142+
});
143+
renderWorkspace();
144+
const { user, dialog } = await confirmDelete();
145+
await screen.findByRole("alert");
146+
await user.click(within(dialog).getByRole("button", { name: "Delete workspace" }));
147+
await waitFor(() => {
148+
expect(deletes).toBe(2);
149+
});
150+
});
151+
152+
it("does not keep a stale error after cancel and reopen", async () => {
153+
mockHoncho({
154+
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
155+
});
156+
renderWorkspace();
157+
const { user, dialog } = await confirmDelete();
158+
await screen.findByRole("alert");
159+
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
160+
await user.click(await screen.findByRole("button", { name: "Delete" }));
161+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
162+
});
163+
164+
it("shows a fallback message when deletion fails without an API detail", async () => {
165+
mockHoncho({
166+
onDelete: () => Promise.reject(new Error("Network request failed")),
167+
});
168+
renderWorkspace();
169+
await confirmDelete();
170+
expect((await screen.findByRole("alert")).textContent).toBe("Network request failed");
171+
});
172+
173+
it("navigates to the workspace list after a successful deletion", async () => {
174+
mockHoncho();
175+
const { router } = renderWorkspace();
176+
await confirmDelete();
177+
await waitFor(() => {
178+
expect(router.state.location.pathname).toBe("/workspaces");
179+
});
180+
});
181+
182+
it("does not render an alert when ConfirmDialog has no error", () => {
183+
render(
184+
<ConfirmDialog
185+
open
186+
title="Delete webhook"
187+
description="This endpoint will stop receiving events immediately."
188+
onConfirm={() => {}}
189+
onCancel={() => {}}
190+
/>,
191+
);
192+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
193+
});
194+
195+
it("does not put the instance token in the delete dialog", async () => {
196+
mockHoncho({
197+
onDelete: () => json({ detail: CONFLICT_DETAIL }, 409),
198+
});
199+
renderWorkspace();
200+
await confirmDelete();
201+
await screen.findByRole("alert");
202+
expect(screen.getByRole("dialog").textContent).not.toContain(INSTANCE.token);
203+
});
204+
});

0 commit comments

Comments
 (0)