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 (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..4fcd847
--- /dev/null
+++ b/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "lucide-react"
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
new file mode 100644
index 0000000..56a7734
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
index b028285..d6a650f 100644
--- a/web/src/test/fixtures.ts
+++ b/web/src/test/fixtures.ts
@@ -32,3 +32,33 @@ export const objectsPage: AdminObjectPage = {
limit: 50,
offset: 0,
};
+
+export type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
+export type TermView = components["schemas"]["TermView"];
+export type AuthorityView = components["schemas"]["AuthorityView"];
+
+export const fieldDefinitions: FieldDefinitionView[] = [
+ { key: "inscription", data_type: "text", vocabulary_id: null, authority_kind: null,
+ required: true, group: "Description", labels: [{ lang: "en", label: "Inscription" }, { lang: "sv", label: "Inskription" }] },
+ { key: "count_seen", data_type: "integer", vocabulary_id: null, authority_kind: null,
+ required: false, group: null, labels: [{ lang: "en", label: "Count seen" }] },
+ { key: "made_on", data_type: "date", vocabulary_id: null, authority_kind: null,
+ required: false, group: null, labels: [{ lang: "en", label: "Made on" }] },
+ { key: "is_fragment", data_type: "boolean", vocabulary_id: null, authority_kind: null,
+ required: false, group: null, labels: [{ lang: "en", label: "Is fragment" }] },
+ { key: "title_ml", data_type: "localized_text", vocabulary_id: null, authority_kind: null,
+ required: false, group: null, labels: [{ lang: "en", label: "Title" }] },
+ { key: "material", data_type: "term", vocabulary_id: "v-material", authority_kind: null,
+ required: false, group: null, labels: [{ lang: "en", label: "Material" }] },
+ { key: "maker", data_type: "authority", vocabulary_id: null, authority_kind: "person",
+ required: false, group: null, labels: [{ lang: "en", label: "Maker" }] },
+];
+
+export const materialTerms: TermView[] = [
+ { id: "t-bronze", external_uri: null, labels: [{ lang: "en", label: "Bronze" }, { lang: "sv", label: "Brons" }] },
+ { id: "t-wood", external_uri: null, labels: [{ lang: "en", label: "Wood" }] },
+];
+
+export const personAuthorities: AuthorityView[] = [
+ { id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
+];
diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts
index 1a01673..2531d0b 100644
--- a/web/src/test/handlers.ts
+++ b/web/src/test/handlers.ts
@@ -1,6 +1,6 @@
import { http, HttpResponse } from "msw";
-import { amphora, fibula, objectsPage } from "./fixtures";
+import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities } from "./fixtures";
export const handlers = [
http.get("/api/admin/me", () =>
@@ -15,20 +15,26 @@ export const handlers = [
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
}),
- http.get("/api/admin/field-definitions", () =>
- HttpResponse.json([
- {
- key: "material",
- data_type: "term",
- vocabulary_id: "v1",
- authority_kind: null,
- required: false,
- group: null,
- labels: [{ lang: "en", label: "Material" }],
- },
- ]),
+ http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
+
+ http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
+
+ http.get("/api/admin/authorities", ({ request }) => {
+ const kind = new URL(request.url).searchParams.get("kind");
+
+ return HttpResponse.json(kind === "person" ? personAuthorities : []);
+ }),
+
+ http.post("/api/admin/objects", () =>
+ HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }),
),
+ http.put("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
+
+ http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 204 })),
+
+ http.delete("/api/admin/objects/:id", () => 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 })),