diff --git a/src/app/directory/[id]/DirectoryOwnerActions.test.tsx b/src/app/directory/[id]/DirectoryOwnerActions.test.tsx
new file mode 100644
index 00000000..0e785ed1
--- /dev/null
+++ b/src/app/directory/[id]/DirectoryOwnerActions.test.tsx
@@ -0,0 +1,74 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { DirectoryOwnerActions } from "./DirectoryOwnerActions";
+
+const { push, refresh } = vi.hoisted(() => ({
+ push: vi.fn(),
+ refresh: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push, refresh }),
+}));
+
+const listing = {
+ id: "listing-1",
+ title: "Example",
+ url: "https://example.com",
+ description: null,
+ tags: [],
+ logo_url: null,
+ banner_url: null,
+ screenshot_url: null,
+ status: "active",
+};
+
+describe("DirectoryOwnerActions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("fetch", vi.fn());
+ vi.stubGlobal("confirm", vi.fn(() => true));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the listing in place when a visibility update fails", async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(JSON.stringify({ error: "Visibility denied" }), { status: 403 })
+ );
+
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Hide" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Visibility denied");
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("keeps the listing in place when deletion fails", async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(JSON.stringify({ error: "Delete denied" }), { status: 403 })
+ );
+
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Delete denied");
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("re-enables saving when an edit request fails at the network layer", async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error("offline"));
+
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Edit" }));
+ await userEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Update failed");
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/directory/[id]/DirectoryOwnerActions.tsx b/src/app/directory/[id]/DirectoryOwnerActions.tsx
index eaddcc9c..3f21bae1 100644
--- a/src/app/directory/[id]/DirectoryOwnerActions.tsx
+++ b/src/app/directory/[id]/DirectoryOwnerActions.tsx
@@ -22,6 +22,15 @@ interface DirectoryOwnerActionsProps {
};
}
+async function responseError(res: Response, fallback: string) {
+ try {
+ const data = await res.json();
+ return data.error || fallback;
+ } catch {
+ return fallback;
+ }
+}
+
export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
@@ -45,49 +54,76 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
.map((t) => t.trim())
.filter(Boolean);
- const res = await fetch(`/api/directory/${listing.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title,
- url,
- description: description || undefined,
- tags,
- logo_url: logoUrl || null,
- banner_url: bannerUrl || null,
- screenshot_url: screenshotUrl || null,
- }),
- });
-
- if (!res.ok) {
- const data = await res.json();
- setError(data.error || "Update failed");
+ try {
+ const res = await fetch(`/api/directory/${listing.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title,
+ url,
+ description: description || undefined,
+ tags,
+ logo_url: logoUrl || null,
+ banner_url: bannerUrl || null,
+ screenshot_url: screenshotUrl || null,
+ }),
+ });
+
+ if (!res.ok) {
+ setError(await responseError(res, "Update failed"));
+ return;
+ }
+
+ setEditing(false);
+ router.refresh();
+ } catch {
+ setError("Update failed");
+ } finally {
setLoading(false);
- return;
}
-
- setEditing(false);
- setLoading(false);
- router.refresh();
}
async function handleToggleVisibility() {
+ setError("");
setLoading(true);
- const newStatus = listing.status === "active" ? "hidden" : "active";
- await fetch(`/api/directory/${listing.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ status: newStatus }),
- });
- setLoading(false);
- router.refresh();
+ try {
+ const newStatus = listing.status === "active" ? "hidden" : "active";
+ const res = await fetch(`/api/directory/${listing.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status: newStatus }),
+ });
+
+ if (!res.ok) {
+ setError(await responseError(res, "Visibility update failed"));
+ return;
+ }
+
+ router.refresh();
+ } catch {
+ setError("Visibility update failed");
+ } finally {
+ setLoading(false);
+ }
}
async function handleDelete() {
if (!confirm("Delete this listing? This cannot be undone and there is no refund.")) return;
+ setError("");
setLoading(true);
- await fetch(`/api/directory/${listing.id}`, { method: "DELETE" });
- router.push("/directory");
+ try {
+ const res = await fetch(`/api/directory/${listing.id}`, { method: "DELETE" });
+ if (!res.ok) {
+ setError(await responseError(res, "Delete failed"));
+ return;
+ }
+
+ router.push("/directory");
+ } catch {
+ setError("Delete failed");
+ } finally {
+ setLoading(false);
+ }
}
if (editing) {
@@ -95,7 +131,7 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
Edit Listing
{error && (
-
+
{error}
)}
@@ -130,7 +166,7 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
-
+
@@ -140,8 +176,13 @@ export function DirectoryOwnerActions({ listing }: DirectoryOwnerActionsProps) {
return (
Owner Actions
+ {error && (
+
+ {error}
+
+ )}
-