From 282e6430d4abbb98c0654257f617a0951fda88cb Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 20:06:23 +0200 Subject: [PATCH] feat(web): mutation hooks + InUseError + i18n for reference-data edit/delete --- web/src/api/queries.test.ts | 11 +++ web/src/api/queries.ts | 164 ++++++++++++++++++++++++++++++++++++ web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 web/src/api/queries.test.ts diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts new file mode 100644 index 0000000..bd45710 --- /dev/null +++ b/web/src/api/queries.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; + +import { InUseError } from "./queries"; + +describe("InUseError", () => { + it("carries the count", () => { + const e = new InUseError(7); + expect(e.count).toBe(7); + expect(e).toBeInstanceOf(Error); + }); +}); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 5741123..c4c82fe 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -17,6 +17,13 @@ export class FieldRejection extends Error { } } +export class InUseError extends Error { + constructor(public readonly count: number) { + super(`in use: ${count}`); + this.name = "InUseError"; + } +} + type UserView = components["schemas"]["UserView"]; type LoginRequest = components["schemas"]["LoginRequest"]; @@ -381,3 +388,160 @@ export function useSetVisibility() { }, }); } + +export function useUpdateTerm() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + vocabularyId, + termId, + external_uri, + labels, + }: { + vocabularyId: string; + termId: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + body: { external_uri, labels }, + }); + + if (response.status !== 204) throw new Error("update term failed"); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + }); +} + +export function useDeleteTerm() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete term failed"); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + }); +} + +export function useRenameVocabulary() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, key }: { id: string; key: string }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}", { + params: { path: { id } }, + body: { key }, + }); + + if (response.status !== 204) throw new Error("rename failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + }); +} + +export function useDeleteVocabulary() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", { + params: { path: { id } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete vocabulary failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + }); +} + +export function useUpdateAuthority() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + external_uri, + labels, + }: { + id: string; + kind: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/authorities/{id}", { + params: { path: { id } }, + body: { external_uri, labels }, + }); + + if (response.status !== 204) throw new Error("update authority failed"); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + }); +} + +export function useDeleteAuthority() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id }: { id: string; kind: string }) => { + const { error, response } = await api.DELETE("/api/admin/authorities/{id}", { + params: { path: { id } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete authority failed"); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + }); +} + +export function useUpdateFieldDefinition() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + key, + required, + group, + labels, + }: { + key: string; + required: boolean; + group: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/field-definitions/{key}", { + params: { path: { key } }, + body: { required, group, labels }, + }); + + if (response.status !== 204) throw new Error("update field failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + }); +} + +export function useDeleteFieldDefinition() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (key: string) => { + const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", { + params: { path: { key } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete field failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + }); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 8f798b5..2ed788b 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -6,7 +6,7 @@ "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, - "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, + "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "labels": { "label": "Label", "externalUri": "External URI (optional)" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 725ab38..456d269 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -6,7 +6,7 @@ "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, - "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, + "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel",