From 50d251212388992846dc753ce59813a01f1d940e Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 20:16:17 +0200 Subject: [PATCH] refactor(web): term/authority rows + pages adopt shared CRUD components (#64) --- web/src/authorities/authorities-page.tsx | 122 ++++------------------- web/src/authorities/authority-row.tsx | 94 +++-------------- web/src/vocab/term-row.tsx | 88 +++------------- web/src/vocab/vocabulary-terms.tsx | 118 ++++------------------ 4 files changed, 69 insertions(+), 353 deletions(-) diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 97e18a3..85035c6 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -1,26 +1,16 @@ -import { useState, type FormEvent } from "react"; import { NavLink, Navigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import type { components } from "../api/schema"; import { useAuthorities, useCreateAuthority } from "../api/queries"; -import { LabelEditor } from "../components/label-editor"; -import { MutationError } from "../components/mutation-error"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { FilteredRecordList } from "../components/filtered-record-list"; +import { LabelledRecordCreateForm } from "../components/labelled-record-create-form"; import { PageTitle } from "@/components/ui/page-title"; -import { ListSkeleton } from "@/components/ui/skeletons"; import { AuthorityRow } from "./authority-row"; -import { byLabel } from "../lib/sort"; -import { labelText } from "../lib/labels"; import { focusRing } from "../lib/focus-ring"; import { useDocumentTitle } from "../lib/use-document-title"; import { useBreadcrumb } from "../shell/use-breadcrumb"; import { cn } from "@/lib/utils"; -type LabelInput = components["schemas"]["LabelInput"]; - const KINDS = ["person", "organisation", "place"] as const; export function AuthoritiesPage() { @@ -34,36 +24,11 @@ export function AuthoritiesPage() { const { data: authorities, isLoading, isError } = useAuthorities(currentKind); const create = useCreateAuthority(); - const [labels, setLabels] = useState([]); - const [error, setError] = useState(false); - const [filter, setFilter] = useState(""); - const [uri, setUri] = useState(""); - useDocumentTitle(t("nav.authorities")); useBreadcrumb([{ label: t("nav.authorities") }]); if (!isValidKind) return ; - const onCreate = (event: FormEvent) => { - event.preventDefault(); - - if (!labels.some((l) => l.label)) { - setError(true); - return; - } - - setError(false); - create.mutate( - { kind: kind as string, external_uri: uri.trim() || null, labels }, - { - onSuccess: () => { - setLabels([]); - setUri(""); - }, - }, - ); - }; - return (
{t("nav.authorities")} @@ -81,73 +46,24 @@ export function AuthoritiesPage() { ))} -
- setFilter(e.target.value)} - /> -
+ } + /> - {isLoading ? ( - - ) : ( - (() => { - const q = filter.trim().toLowerCase(); - const rows = [...(authorities ?? [])] - .filter((a) => !q || labelText(a.labels, lang).toLowerCase().includes(q)) - .sort(byLabel(lang)); - - return ( -
    - {isError && ( -
  • {t("authorities.loadError")}
  • - )} - {!isError && authorities?.length === 0 && ( -
  • {t("authorities.empty")}
  • - )} - {!isError && authorities && authorities.length > 0 && rows.length === 0 && ( -
  • {t("common.noMatches")}
  • - )} - {rows.map((a) => ( - - ))} -
- ); - })() - )} - -
-
- {t("authorities.new")} · {t(`authorities.${currentKind}`)} -
- - - -
- - setUri(e.target.value)} - /> -
- - {error && ( -

- {t("form.required")} -

- )} - - - - - + + create.mutate({ kind: currentKind, external_uri: uri, labels }, { onSuccess: reset })} + />
); } diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx index 04695db..ee06308 100644 --- a/web/src/authorities/authority-row.tsx +++ b/web/src/authorities/authority-row.tsx @@ -1,90 +1,24 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - import type { components } from "../api/schema"; import { useUpdateAuthority, useDeleteAuthority } from "../api/queries"; -import { LabelEditor } from "../components/label-editor"; -import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; -import { MutationError } from "../components/mutation-error"; -import { ExternalUriLink } from "../components/external-uri-link"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { labelText } from "../lib/labels"; +import { LabelledRecordRow } from "../components/labelled-record-row"; type AuthorityView = components["schemas"]["AuthorityView"]; -type LabelInput = components["schemas"]["LabelInput"]; export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) { - const { t } = useTranslation(); - - const updateAuthority = useUpdateAuthority(); - const deleteAuthority = useDeleteAuthority(); - - const [editing, setEditing] = useState(false); - const [labels, setLabels] = useState(authority.labels as LabelInput[]); - const [uri, setUri] = useState(authority.external_uri ?? ""); - - if (editing) { - return ( -
  • - -
    - - setUri(e.target.value)} - /> -
    -
    - - -
    - -
  • - ); - } + const update = useUpdateAuthority(); + const del = useDeleteAuthority(); return ( -
  • -
    -
    {labelText(authority.labels, lang)}
    - {authority.external_uri && } -
    - - deleteAuthority.mutateAsync({ id: authority.id, kind })} - /> -
  • + update.reset()} + onSave={(labels, uri, done) => + update.mutate({ id: authority.id, kind, external_uri: uri, labels }, { onSuccess: done })} + onDelete={() => del.mutateAsync({ id: authority.id, kind })} + /> ); } diff --git a/web/src/vocab/term-row.tsx b/web/src/vocab/term-row.tsx index 69261e1..cf777e4 100644 --- a/web/src/vocab/term-row.tsx +++ b/web/src/vocab/term-row.tsx @@ -1,84 +1,24 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - import type { components } from "../api/schema"; import { useUpdateTerm, useDeleteTerm } from "../api/queries"; -import { LabelEditor } from "../components/label-editor"; -import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; -import { MutationError } from "../components/mutation-error"; -import { ExternalUriLink } from "../components/external-uri-link"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { labelText } from "../lib/labels"; +import { LabelledRecordRow } from "../components/labelled-record-row"; type TermView = components["schemas"]["TermView"]; -type LabelInput = components["schemas"]["LabelInput"]; export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) { - const { t } = useTranslation(); - - const updateTerm = useUpdateTerm(); - const deleteTerm = useDeleteTerm(); - - const [editing, setEditing] = useState(false); - const [labels, setLabels] = useState(term.labels as LabelInput[]); - const [uri, setUri] = useState(term.external_uri ?? ""); - - if (editing) { - return ( -
  • - -
    - - setUri(e.target.value)} /> -
    -
    - - -
    - -
  • - ); - } + const update = useUpdateTerm(); + const del = useDeleteTerm(); return ( -
  • -
    -
    {labelText(term.labels, lang)}
    - {term.external_uri && } -
    - - deleteTerm.mutateAsync({ vocabularyId, termId: term.id })} - /> -
  • + update.reset()} + onSave={(labels, uri, done) => + update.mutate({ vocabularyId, termId: term.id, external_uri: uri, labels }, { onSuccess: done })} + onDelete={() => del.mutateAsync({ vocabularyId, termId: term.id })} + /> ); } diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index d31cc32..402379e 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -1,41 +1,20 @@ -import { useState, type FormEvent } from "react"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import type { components } from "../api/schema"; import { useTerms, useAddTerm, useVocabularies } from "../api/queries"; -import { byLabel } from "../lib/sort"; -import { labelText } from "../lib/labels"; import { useBreadcrumb } from "../shell/use-breadcrumb"; -import { LabelEditor } from "../components/label-editor"; -import { MutationError } from "../components/mutation-error"; +import { FilteredRecordList } from "../components/filtered-record-list"; +import { LabelledRecordCreateForm } from "../components/labelled-record-create-form"; import { TermRow } from "./term-row"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ListSkeleton } from "@/components/ui/skeletons"; - -type LabelInput = components["schemas"]["LabelInput"]; export function VocabularyTerms() { const { t, i18n } = useTranslation(); - const { id } = useParams(); - const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const { data: terms, isLoading, isError } = useTerms(id); - const addTerm = useAddTerm(); - const [labels, setLabels] = useState([]); - - const [uri, setUri] = useState(""); - - const [filter, setFilter] = useState(""); - - const [error, setError] = useState(false); - const { data: vocabularies } = useVocabularies(); const vocabKey = vocabularies?.find((v) => v.id === id)?.key; @@ -47,81 +26,28 @@ export function VocabularyTerms() { if (!id) return null; - const onAdd = (event: FormEvent) => { - event.preventDefault(); - - if (!labels.some((l) => l.label)) { - setError(true); - return; - } - - setError(false); - - addTerm.mutate( - { vocabularyId: id, external_uri: uri.trim() || null, labels }, - { onSuccess: () => { setLabels([]); setUri(""); } }, - ); - }; - - const q = filter.trim().toLowerCase(); - const rows = [...(terms ?? [])] - .filter((term) => !q || labelText(term.labels, lang).toLowerCase().includes(q)) - .sort(byLabel(lang)); - return (
    -
    - {t("vocab.terms")} -
    -
    - setFilter(e.target.value)} - /> -
    - {isLoading ? ( - - ) : ( -
      - {isError && ( -
    • {t("vocab.loadError")}
    • - )} - {!isError && terms?.length === 0 && ( -
    • {t("vocab.noTerms")}
    • - )} - {!isError && terms && terms.length > 0 && rows.length === 0 && ( -
    • {t("common.noMatches")}
    • - )} - {rows.map((term) => ( - - ))} -
    - )} -
    -
    {t("vocab.addTerm")}
    - -
    - - setUri(e.target.value)} - /> -
    - {error && ( -

    - {t("form.required")} -

    - )} - - - +
    {t("vocab.terms")}
    + + } + /> + + + addTerm.mutate({ vocabularyId: id, external_uri: uri, labels }, { onSuccess: reset })} + />
    ); }