diff --git a/web/package.json b/web/package.json index 15d012d..a9dc0bb 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "openapi-fetch": "^0.17.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.77.0", "react-i18next": "^17.0.8", "react-router-dom": "^7.16.0", "tailwind-merge": "^3.6.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7ceec43..5186ed4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.77.0 + version: 7.77.0(react@19.2.7) react-i18next: specifier: ^17.0.8 version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3) @@ -2420,6 +2423,12 @@ packages: peerDependencies: react: ^19.2.7 + react-hook-form@7.77.0: + resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.8: resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} peerDependencies: @@ -5200,6 +5209,10 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-hook-form@7.77.0(react@19.2.7): + dependencies: + react: 19.2.7 + react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3): dependencies: '@babel/runtime': 7.29.7 diff --git a/web/src/api/queries.authoring.test.tsx b/web/src/api/queries.authoring.test.tsx new file mode 100644 index 0000000..740db3d --- /dev/null +++ b/web/src/api/queries.authoring.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useAuthorities, + useCreateObject, + useDeleteObject, + useSetFields, + useTerms, + useUpdateObject, +} from "./queries"; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return {children}; +} + +describe("authoring hooks", () => { + test("useTerms loads a vocabulary's terms", async () => { + const { result } = renderHook(() => useTerms("v-material"), { wrapper }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.[0].id).toBe("t-bronze"); + }); + + test("useAuthorities loads by kind", async () => { + const { result } = renderHook(() => useAuthorities("person"), { wrapper }); + + await waitFor(() => expect(result.current.data?.length).toBe(1)); + expect(result.current.data?.[0].id).toBe("a-ada"); + }); + + test("useCreateObject returns the new id", async () => { + const { result } = renderHook(() => useCreateObject(), { wrapper }); + + const created = await result.current.mutateAsync({ + object_number: "A-1", + object_name: "x", + number_of_objects: 1, + visibility: "draft", + }); + + expect(created.id).toBe("11111111-1111-1111-1111-111111111111"); + }); + + test("useSetFields / useUpdateObject / useDeleteObject resolve", async () => { + const setFields = renderHook(() => useSetFields(), { wrapper }); + + await setFields.result.current.mutateAsync({ id: "o1", fields: { inscription: "hi" } }); + + const update = renderHook(() => useUpdateObject(), { wrapper }); + + await update.result.current.mutateAsync({ + id: "o1", + body: { object_number: "A-1", object_name: "x", number_of_objects: 1 }, + }); + + const del = renderHook(() => useDeleteObject(), { wrapper }); + + await del.result.current.mutateAsync("o1"); + + expect(true).toBe(true); + }); +}); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index e500245..ad3aeb6 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -95,3 +95,107 @@ export function useLogout() { onSuccess: () => qc.setQueryData(["me"], null), }); } + +type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; +type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; + +export function useTerms(vocabularyId: string | null | undefined) { + return useQuery({ + queryKey: ["terms", vocabularyId], + enabled: !!vocabularyId, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { + params: { path: { id: vocabularyId! } }, + }); + + if (error || !data) throw new Error("failed to load terms"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useAuthorities(kind: string | null | undefined) { + return useQuery({ + queryKey: ["authorities", kind], + enabled: !!kind, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/authorities", { + params: { query: { kind: kind! } }, + }); + + if (error || !data) throw new Error("failed to load authorities"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (body: ObjectCreateRequest) => { + const { data, error } = await api.POST("/api/admin/objects", { body }); + + if (error || !data) throw new Error("create failed"); + + return data; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + }); +} + +export function useUpdateObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => { + const { response } = await api.PUT("/api/admin/objects/{id}", { + params: { path: { id } }, + body, + }); + + if (response.status !== 204) throw new Error("update failed"); + }, + onSuccess: (_d, { id }) => { + void qc.invalidateQueries({ queryKey: ["objects"] }); + void qc.invalidateQueries({ queryKey: ["object", id] }); + }, + }); +} + +export function useSetFields() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, fields }: { id: string; fields: Record }) => { + const { response } = await api.PUT("/api/admin/objects/{id}/fields", { + params: { path: { id } }, + body: fields as Record, + }); + + if (response.status !== 204) throw new Error("set fields failed"); + }, + onSuccess: (_d, { id }) => { + void qc.invalidateQueries({ queryKey: ["object", id] }); + }, + }); +} + +export function useDeleteObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { response } = await api.DELETE("/api/admin/objects/{id}", { + params: { path: { id } }, + }); + + if (response.status !== 204) throw new Error("delete failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + }); +} diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..ead776d --- /dev/null +++ b/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +