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