feat(web): useSetVisibility hook + adjacentTransitions helper + MSW handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 08:30:15 +02:00
parent 516ecf3e95
commit 01f757a239
5 changed files with 96 additions and 0 deletions
+29
View File
@@ -199,3 +199,32 @@ export function useDeleteObject() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
}); });
} }
type Visibility = "draft" | "internal" | "public";
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
export class VisibilityError extends Error {
constructor(public status: number) {
super(`visibility change failed (${status})`);
this.name = "VisibilityError";
}
}
export function useSetVisibility() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
params: { path: { id } },
body: { visibility },
});
if (response.status !== 204) throw new VisibilityError(response.status);
},
onSuccess: (_result, { id }) => {
void qc.invalidateQueries({ queryKey: ["object", id] });
void qc.invalidateQueries({ queryKey: ["objects"] });
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "../test/server";
import { useSetVisibility } from "./queries";
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("useSetVisibility", () => {
test("POSTs the target visibility and resolves on 204", async () => {
let body: unknown;
server.use(
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
body = await request.json();
return new HttpResponse(null, { status: 204 });
}),
);
const { result } = renderHook(() => useSetVisibility(), { wrapper });
await result.current.mutateAsync({ id: "o1", visibility: "internal" });
expect((body as { visibility: string }).visibility).toBe("internal");
});
test("throws a status-carrying error on 422 (publish gate)", async () => {
server.use(
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
);
const { result } = renderHook(() => useSetVisibility(), { wrapper });
await expect(
result.current.mutateAsync({ id: "o1", visibility: "public" }),
).rejects.toMatchObject({ status: 422 });
});
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "vitest";
import { adjacentTransitions } from "./transitions";
test("draft can only go forward to internal", () => {
expect(adjacentTransitions("draft")).toEqual({ forward: "internal" });
});
test("internal can go forward to public and back to draft", () => {
expect(adjacentTransitions("internal")).toEqual({ forward: "public", back: "draft" });
});
test("public can only go back to internal", () => {
expect(adjacentTransitions("public")).toEqual({ back: "internal" });
});
+14
View File
@@ -0,0 +1,14 @@
export type Visibility = "draft" | "internal" | "public";
/** The legal one-step visibility moves from `v`, per the backend state machine
* (Draft<->Internal, Internal<->Public; no skipping). */
export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } {
switch (v) {
case "draft":
return { forward: "internal" };
case "internal":
return { forward: "public", back: "draft" };
case "public":
return { back: "internal" };
}
}
+2
View File
@@ -38,4 +38,6 @@ export const handlers = [
http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })), http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })), http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })),
]; ];