feat(web): shared FilteredRecordList component (#64)
This commit is contained in:
@@ -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) => <li>{labelText(r.labels, "en")}</li>;
|
||||||
|
|
||||||
|
test("filtering narrows the rendered rows", async () => {
|
||||||
|
renderApp(
|
||||||
|
<FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
|
||||||
|
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<FilteredRecordList records={[]} lang="en" isLoading={false} isError={false}
|
||||||
|
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("EmptyMsg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-empty records with a non-matching filter show no-matches", async () => {
|
||||||
|
renderApp(
|
||||||
|
<FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
|
||||||
|
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText(/filter/i), "zzz");
|
||||||
|
expect(screen.getByText(/no matches/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an error shows the load-error text", () => {
|
||||||
|
renderApp(
|
||||||
|
<FilteredRecordList records={undefined} lang="en" isLoading={false} isError={true}
|
||||||
|
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("LoadErr")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -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<T extends { id: string; labels: LabelView[] }>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<Input
|
||||||
|
aria-label={t("common.filter")}
|
||||||
|
placeholder={t("common.filter")}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<ListSkeleton className="mb-4" rows={5} />
|
||||||
|
) : (
|
||||||
|
<ul className="mb-4">
|
||||||
|
{isError && <li className="text-sm text-destructive">{loadErrorText}</li>}
|
||||||
|
{!isError && records?.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground">{emptyText}</li>
|
||||||
|
)}
|
||||||
|
{!isError && records && records.length > 0 && rows.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
|
||||||
|
)}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Fragment key={r.id}>{renderRow(r)}</Fragment>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user