feat(web): use searchable combobox for term/authority fields on the object form (#27)

This commit is contained in:
2026-06-06 10:41:28 +02:00
parent 0188e730e8
commit c84b84b153
3 changed files with 64 additions and 66 deletions
+56 -7
View File
@@ -1,10 +1,13 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { renderApp } from "../test/render"; import { renderApp } from "../test/render";
import { FieldInput } from "./field-input"; import { FieldInput } from "./field-input";
import { fieldDefinitions } from "../test/fixtures"; import { fieldDefinitions } from "../test/fixtures";
type FormValues = { fields: Record<string, unknown> };
function Harness({ defKey }: { defKey: string }) { function Harness({ defKey }: { defKey: string }) {
const def = fieldDefinitions.find((d) => d.key === defKey)!; const def = fieldDefinitions.find((d) => d.key === defKey)!;
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } }); const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
@@ -12,6 +15,24 @@ function Harness({ defKey }: { defKey: string }) {
return <FieldInput definition={def} form={form} />; return <FieldInput definition={def} form={form} />;
} }
function FormHarness({
defKey,
onSubmit,
}: {
defKey: string;
onSubmit: (values: FormValues) => void;
}) {
const def = fieldDefinitions.find((d) => d.key === defKey)!;
const form = useForm<FormValues>({ defaultValues: { fields: {} as Record<string, unknown> } });
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<FieldInput definition={def} form={form} />
<button type="submit">Submit</button>
</form>
);
}
test("text field renders a text input labelled in the active locale", async () => { test("text field renders a text input labelled in the active locale", async () => {
renderApp(<Harness defKey="inscription" />); renderApp(<Harness defKey="inscription" />);
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument(); expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
@@ -27,12 +48,40 @@ test("localized_text renders a single input for the default language", async ()
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument(); expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
}); });
test("term field renders a select populated from the vocabulary", async () => { test("term field filters and selects from the vocabulary combobox", async () => {
renderApp(<Harness defKey="material" />); const user = userEvent.setup();
expect(await screen.findByText("Bronze")).toBeInTheDocument(); const submitted: FormValues[] = [];
renderApp(<FormHarness defKey="material" onSubmit={(v) => submitted.push(v)} />);
const input = await screen.findByPlaceholderText("— select —");
await user.click(input);
await user.type(input, "bro");
const body = within(document.body);
await user.click(await body.findByText("Bronze"));
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submitted[0]?.fields.material).toBe("t-bronze");
}); });
test("authority field renders a select populated by kind", async () => { test("authority field filters and selects from the authority combobox", async () => {
renderApp(<Harness defKey="maker" />); const user = userEvent.setup();
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); const submitted: FormValues[] = [];
renderApp(<FormHarness defKey="maker" onSubmit={(v) => submitted.push(v)} />);
const input = await screen.findByPlaceholderText("— select —");
await user.click(input);
await user.type(input, "ada");
const body = within(document.body);
await user.click(await body.findByText("Ada Lovelace"));
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submitted[0]?.fields.maker).toBe("a-ada");
}); });
+5 -48
View File
@@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema"; import type { components } from "../api/schema";
import { useAuthorities, useTerms } from "../api/queries"; import { useAuthorities, useTerms } from "../api/queries";
import { useConfig } from "../config/config-context"; import { useConfig } from "../config/config-context";
import { labelText } from "../lib/labels";
import { OptionsCombobox } from "./options-combobox";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
type LabelView = components["schemas"]["LabelView"];
type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>; type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>;
@@ -19,50 +20,6 @@ function fieldPath<TValues extends { fields: Record<string, unknown> }>(
return `fields.${key}` as Path<TValues>; return `fields.${key}` as Path<TValues>;
} }
function labelIn(labels: LabelView[], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
// A native <select> keeps the bundle lean and is fully accessible; the shadcn Select
// can replace it later without changing the value contract (option value = id).
function OptionsSelect({
id,
value,
onChange,
options,
lang,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
options: { id: string; labels: LabelView[] }[];
lang: string;
placeholder: string;
}) {
return (
<select
id={id}
className="w-full rounded border px-2 py-1 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{labelIn(o.labels, lang)}
</option>
))}
</select>
);
}
export function FieldInput<TValues extends { fields: Record<string, unknown> }>({ export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
definition, definition,
form, form,
@@ -73,7 +30,7 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { default_language } = useConfig(); const { default_language } = useConfig();
const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const label = labelIn(definition.labels, lang); const label = labelText(definition.labels, lang);
const name = fieldPath<TValues>(definition.key); const name = fieldPath<TValues>(definition.key);
const placeholder = t("form.selectPlaceholder"); const placeholder = t("form.selectPlaceholder");
@@ -201,7 +158,7 @@ function TermField<TValues extends { fields: Record<string, unknown> }>({
name={fieldPath<TValues>(definition.key)} name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }} rules={{ required: definition.required }}
render={({ field }) => ( render={({ field }) => (
<OptionsSelect <OptionsCombobox
id={definition.key} id={definition.key}
value={(field.value as string) ?? ""} value={(field.value as string) ?? ""}
onChange={field.onChange} onChange={field.onChange}
@@ -239,7 +196,7 @@ function AuthorityField<TValues extends { fields: Record<string, unknown> }>({
name={fieldPath<TValues>(definition.key)} name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }} rules={{ required: definition.required }}
render={({ field }) => ( render={({ field }) => (
<OptionsSelect <OptionsCombobox
id={definition.key} id={definition.key}
value={(field.value as string) ?? ""} value={(field.value as string) ?? ""}
onChange={field.onChange} onChange={field.onChange}
+3 -11
View File
@@ -1,4 +1,5 @@
import type { components } from "../api/schema"; import type { components } from "../api/schema";
import { labelText } from "../lib/labels";
import { import {
ComboboxRoot, ComboboxRoot,
ComboboxInputGroup, ComboboxInputGroup,
@@ -16,15 +17,6 @@ type LabelView = components["schemas"]["LabelView"];
export type Option = { id: string; labels: LabelView[] }; export type Option = { id: string; labels: LabelView[] };
export function labelIn(labels: LabelView[], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
export function OptionsCombobox({ export function OptionsCombobox({
id, id,
value, value,
@@ -47,7 +39,7 @@ export function OptionsCombobox({
items={options} items={options}
value={selected} value={selected}
onValueChange={(option) => onChange(option?.id ?? "")} onValueChange={(option) => onChange(option?.id ?? "")}
itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")} itemToStringLabel={(option) => (option ? labelText(option.labels, lang) : "")}
isItemEqualToValue={(a, b) => a?.id === b?.id} isItemEqualToValue={(a, b) => a?.id === b?.id}
> >
<ComboboxInputGroup> <ComboboxInputGroup>
@@ -62,7 +54,7 @@ export function OptionsCombobox({
{(option: Option) => ( {(option: Option) => (
<ComboboxItem key={option.id} value={option}> <ComboboxItem key={option.id} value={option}>
<ComboboxItemIndicator className="text-indigo-600"></ComboboxItemIndicator> <ComboboxItemIndicator className="text-indigo-600"></ComboboxItemIndicator>
{labelIn(option.labels, lang)} {labelText(option.labels, lang)}
</ComboboxItem> </ComboboxItem>
)} )}
</ComboboxList> </ComboboxList>