From c689b8c0e9ff68d1674acffc87244f6804ba4c1b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 20:11:29 +0200 Subject: [PATCH] feat(web): shared FilteredRecordList component (#64) --- .../components/filtered-record-list.test.tsx | 51 ++++++++++++++ web/src/components/filtered-record-list.tsx | 68 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 web/src/components/filtered-record-list.test.tsx create mode 100644 web/src/components/filtered-record-list.tsx diff --git a/web/src/components/filtered-record-list.test.tsx b/web/src/components/filtered-record-list.test.tsx new file mode 100644 index 0000000..361c273 --- /dev/null +++ b/web/src/components/filtered-record-list.test.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderApp } from "../test/render"; +import { FilteredRecordList } from "./filtered-record-list"; +import { labelText } from "../lib/labels"; + +type Rec = { id: string; labels: { lang: string; label: string }[] }; +const recs: Rec[] = [ + { id: "a", labels: [{ lang: "en", label: "Alpha" }] }, + { id: "b", labels: [{ lang: "en", label: "Beta" }] }, +]; +const row = (r: Rec) =>
  • {labelText(r.labels, "en")}
  • ; + +test("filtering narrows the rendered rows", async () => { + renderApp( + , + ); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/filter/i), "alph"); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.queryByText("Beta")).toBeNull(); +}); + +test("empty records show the empty text", () => { + renderApp( + , + ); + expect(screen.getByText("EmptyMsg")).toBeInTheDocument(); +}); + +test("non-empty records with a non-matching filter show no-matches", async () => { + renderApp( + , + ); + await userEvent.type(screen.getByLabelText(/filter/i), "zzz"); + expect(screen.getByText(/no matches/i)).toBeInTheDocument(); +}); + +test("an error shows the load-error text", () => { + renderApp( + , + ); + expect(screen.getByText("LoadErr")).toBeInTheDocument(); +}); diff --git a/web/src/components/filtered-record-list.tsx b/web/src/components/filtered-record-list.tsx new file mode 100644 index 0000000..c1f74c9 --- /dev/null +++ b/web/src/components/filtered-record-list.tsx @@ -0,0 +1,68 @@ +import { Fragment, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { byLabel } from "../lib/sort"; +import { labelText } from "../lib/labels"; +import { Input } from "@/components/ui/input"; +import { ListSkeleton } from "@/components/ui/skeletons"; + +type LabelView = components["schemas"]["LabelView"]; + +/** Filterable, alphabetically-sorted list of labelled records with the standard + * loading / error / empty / no-matches states. The filter input stays visible + * during load (matching the prior page behaviour). */ +export function FilteredRecordList({ + records, + lang, + isLoading, + isError, + loadErrorText, + emptyText, + renderRow, +}: { + records: T[] | undefined; + lang: string; + isLoading: boolean; + isError: boolean; + loadErrorText: string; + emptyText: string; + renderRow: (record: T) => ReactNode; +}) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + + const q = filter.trim().toLowerCase(); + const rows = [...(records ?? [])] + .filter((r) => !q || labelText(r.labels, lang).toLowerCase().includes(q)) + .sort(byLabel(lang)); + + return ( + <> +
    + setFilter(e.target.value)} + /> +
    + {isLoading ? ( + + ) : ( +
      + {isError &&
    • {loadErrorText}
    • } + {!isError && records?.length === 0 && ( +
    • {emptyText}
    • + )} + {!isError && records && records.length > 0 && rows.length === 0 && ( +
    • {t("common.noMatches")}
    • + )} + {rows.map((r) => ( + {renderRow(r)} + ))} +
    + )} + + ); +}