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(
+
+ {}}
+ onSave={onSave}
+ onDelete={async () => {}}
+ />
+
,
+ );
+ 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(
+
+ {}}
+ onSave={() => {}}
+ onDelete={async () => {}}
+ />
+
,
+ );
+ 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(
+
+ {}}
+ onSave={() => {}}
+ onDelete={onDelete}
+ />
+
,
+ );
+ 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 &&
}
+
+
+
+
+ );
+}