diff --git a/web/src/components/labelled-record-row.test.tsx b/web/src/components/labelled-record-row.test.tsx new file mode 100644 index 0000000..8e3b97a --- /dev/null +++ b/web/src/components/labelled-record-row.test.tsx @@ -0,0 +1,74 @@ +import { expect, test, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderApp } from "../test/render"; +import { LabelledRecordRow, type RecordLike } from "./labelled-record-row"; +import { HttpError } from "../api/queries"; + +const record: RecordLike = { id: "r1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] }; + +test("edit → save calls onSave and closes via done()", async () => { + const onSave = vi.fn((_labels: unknown, _uri: unknown, done: () => void) => done()); + renderApp( + , + ); + await userEvent.click(screen.getByRole("button", { name: /edit/i })); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /save/i })).toBeNull(); +}); + +test("a save error renders inline and the row stays editable", async () => { + renderApp( + , + ); + await userEvent.click(screen.getByRole("button", { name: /edit/i })); + expect(screen.getByRole("alert")).toHaveTextContent(/permission/i); + expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); +}); + +test("confirming delete invokes onDelete", async () => { + const onDelete = vi.fn(async () => {}); + renderApp( + , + ); + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + const dialog = within(document.body); + const confirmButtons = await dialog.findAllByRole("button", { name: /delete/i }); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + expect(onDelete).toHaveBeenCalled(); +}); diff --git a/web/src/components/labelled-record-row.tsx b/web/src/components/labelled-record-row.tsx new file mode 100644 index 0000000..89ac367 --- /dev/null +++ b/web/src/components/labelled-record-row.tsx @@ -0,0 +1,102 @@ +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { LabelEditor } from "./label-editor"; +import { DeleteConfirmDialog } from "./delete-confirm-dialog"; +import { MutationError } from "./mutation-error"; +import { ExternalUriLink } from "./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"; + +type LabelView = components["schemas"]["LabelView"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null }; + +/** One labelled record (term/authority): a display row with edit + delete, or an + * inline editor. All variance (mutation hooks, arg shapes, delete-confirm key) is + * supplied by the caller via callbacks/state — see term-row.tsx / authority-row.tsx. */ +export function LabelledRecordRow({ + record, + lang, + deleteConfirmKey, + savePending, + saveError, + onEditOpen, + onSave, + onDelete, +}: { + record: RecordLike; + lang: string; + deleteConfirmKey: string; + savePending: boolean; + saveError: unknown; + onEditOpen: () => void; + onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void; + onDelete: () => Promise; +}) { + const { t } = useTranslation(); + const uriId = useId(); + + const [editing, setEditing] = useState(false); + const [labels, setLabels] = useState(record.labels as LabelInput[]); + const [uri, setUri] = useState(record.external_uri ?? ""); + + if (editing) { + return ( +
  • + +
    + + setUri(e.target.value)} + /> +
    +
    + + +
    + +
  • + ); + } + + return ( +
  • +
    +
    {labelText(record.labels, lang)}
    + {record.external_uri && } +
    + + +
  • + ); +}