diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index ad3aeb6..b777b8a 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -199,3 +199,32 @@ export function useDeleteObject() { 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"] }); + }, + }); +} diff --git a/web/src/api/queries.visibility.test.tsx b/web/src/api/queries.visibility.test.tsx new file mode 100644 index 0000000..35fa42d --- /dev/null +++ b/web/src/api/queries.visibility.test.tsx @@ -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 {children}; +} + +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 }); + }); +}); diff --git a/web/src/objects/transitions.test.ts b/web/src/objects/transitions.test.ts new file mode 100644 index 0000000..e7d3fd2 --- /dev/null +++ b/web/src/objects/transitions.test.ts @@ -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" }); +}); diff --git a/web/src/objects/transitions.ts b/web/src/objects/transitions.ts new file mode 100644 index 0000000..8e88c20 --- /dev/null +++ b/web/src/objects/transitions.ts @@ -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" }; + } +} diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index 2531d0b..dce24de 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -38,4 +38,6 @@ export const handlers = [ http.post("/api/admin/login", () => 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 })), ];