diff --git a/reactapp/__tests__/components/landingPage/dashboardSearch.test.js b/reactapp/__tests__/components/landingPage/dashboardSearch.test.js
new file mode 100644
index 00000000..d7ef2062
--- /dev/null
+++ b/reactapp/__tests__/components/landingPage/dashboardSearch.test.js
@@ -0,0 +1,174 @@
+import {
+ STOPWORDS,
+ filterDashboards,
+ matchesDashboardSearch,
+ normalizeForSearch,
+ significantTokens,
+} from "components/landingPage/dashboardSearch";
+
+const dashboard = (name, description) => ({ name, description });
+
+describe("normalizeForSearch", () => {
+ test("casefolds", () => {
+ expect(normalizeForSearch("Guatemala")).toBe("guatemala");
+ });
+
+ test("strips accents so an unaccented query still matches", () => {
+ expect(normalizeForSearch("Clasificación")).toBe("clasificacion");
+ expect(normalizeForSearch("Peligro Sevéro")).toBe("peligro severo");
+ });
+
+ test("non-strings normalize to an empty string", () => {
+ // description is nullable on a dashboard record.
+ expect(normalizeForSearch(undefined)).toBe("");
+ expect(normalizeForSearch(null)).toBe("");
+ expect(normalizeForSearch(42)).toBe("");
+ });
+});
+
+describe("significantTokens", () => {
+ test("drops stopwords and keeps the rest", () => {
+ expect(significantTokens("the flood and the depth")).toEqual([
+ "flood",
+ "depth",
+ ]);
+ });
+
+ test("a query of only stopwords yields no tokens", () => {
+ expect(significantTokens("the and a an of")).toEqual([]);
+ });
+
+ test("splits on punctuation, not just spaces", () => {
+ expect(significantTokens("Exercise #2 / flood-depth")).toEqual([
+ "exercise",
+ "2",
+ "flood",
+ "depth",
+ ]);
+ });
+
+ test("digits survive as tokens", () => {
+ expect(significantTokens("hands on 1")).toEqual(["hands", "1"]);
+ });
+
+ test("empty and whitespace queries yield no tokens", () => {
+ expect(significantTokens("")).toEqual([]);
+ expect(significantTokens(" ")).toEqual([]);
+ });
+
+ test("the list covers the words called out as insignificant", () => {
+ for (const word of ["the", "and", "a", "an"]) {
+ expect(STOPWORDS.has(word)).toBe(true);
+ }
+ });
+});
+
+describe("matchesDashboardSearch", () => {
+ const guatemala = dashboard(
+ "Guatemala Hands On 1",
+ "Solution for WMO Guatemala Hands On Exercise #1",
+ );
+
+ test("an empty or whitespace query matches everything", () => {
+ expect(matchesDashboardSearch(guatemala, "")).toBe(true);
+ expect(matchesDashboardSearch(guatemala, " ")).toBe(true);
+ });
+
+ test("matches a partial name, case-insensitively", () => {
+ expect(matchesDashboardSearch(guatemala, "guat")).toBe(true);
+ expect(matchesDashboardSearch(guatemala, "GUATEMALA hands")).toBe(true);
+ });
+
+ test("matches on description words the name does not contain", () => {
+ expect(matchesDashboardSearch(guatemala, "WMO")).toBe(true);
+ expect(matchesDashboardSearch(guatemala, "exercise")).toBe(true);
+ });
+
+ test("ignores stopwords in the query when matching a description", () => {
+ // "for" appears in the description, but the match must not depend on it.
+ expect(matchesDashboardSearch(guatemala, "the solution")).toBe(true);
+ expect(matchesDashboardSearch(guatemala, "a wmo exercise")).toBe(true);
+ });
+
+ test("requires every significant token, not just one", () => {
+ expect(matchesDashboardSearch(guatemala, "solution exercise")).toBe(true);
+ expect(matchesDashboardSearch(guatemala, "solution volcano")).toBe(false);
+ });
+
+ test("tokens match inside longer words", () => {
+ const flood = dashboard("Basin", "Shows flooding across the basin");
+ expect(matchesDashboardSearch(flood, "flood")).toBe(true);
+ });
+
+ test("a stopword-only query does not match via the description", () => {
+ // The whole point: "the" appears in this description, but matching on it
+ // would surface every dashboard in the app.
+ const withThe = dashboard("Basin", "Shows the depth of the basin");
+ expect(matchesDashboardSearch(withThe, "the")).toBe(false);
+ expect(matchesDashboardSearch(withThe, "and the")).toBe(false);
+ });
+
+ test("a stopword-only query still matches a name containing it", () => {
+ // Stopwords are not stripped from the name path, so a dashboard actually
+ // called "The Basin" stays findable by typing "the".
+ const theBasin = dashboard("The Basin", "Depth across a basin");
+ expect(matchesDashboardSearch(theBasin, "the")).toBe(true);
+ });
+
+ test("accents in the record do not need accents in the query", () => {
+ const hazard = dashboard(
+ "Peligro",
+ "Clasificación de peligro por inundación",
+ );
+ expect(matchesDashboardSearch(hazard, "clasificacion")).toBe(true);
+ expect(matchesDashboardSearch(hazard, "inundacion")).toBe(true);
+ });
+
+ test("a missing description does not throw and does not match", () => {
+ const bare = dashboard("Basin", undefined);
+ expect(matchesDashboardSearch(bare, "basin")).toBe(true);
+ expect(matchesDashboardSearch(bare, "depth")).toBe(false);
+ });
+
+ test("a name is matched as a whole substring, spaces included", () => {
+ // "hands on" is contiguous in the name; "on hands" is not, and has no
+ // description support either.
+ expect(matchesDashboardSearch(guatemala, "hands on 1")).toBe(true);
+ const reordered = dashboard("Hands On", "unrelated text");
+ expect(matchesDashboardSearch(reordered, "on hands")).toBe(false);
+ });
+});
+
+describe("filterDashboards", () => {
+ const dashboards = [
+ dashboard("Guatemala Hands On 1", "Solution for WMO Exercise #1"),
+ dashboard("Guatemala Hands On 2", "Solution for WMO Exercise #2"),
+ dashboard("Willamette", "Reservoir forecasts for the Willamette basin"),
+ ];
+
+ test("preserves the original order", () => {
+ const result = filterDashboards(dashboards, "guatemala");
+ expect(result.map((d) => d.name)).toEqual([
+ "Guatemala Hands On 1",
+ "Guatemala Hands On 2",
+ ]);
+ });
+
+ test("an empty query returns every dashboard", () => {
+ expect(filterDashboards(dashboards, "")).toHaveLength(3);
+ });
+
+ test("narrows to one on a description word", () => {
+ expect(filterDashboards(dashboards, "reservoir")).toHaveLength(1);
+ });
+
+ test("a stopword-only query matches nothing here", () => {
+ // "the" appears in the Willamette description but in no name.
+ expect(filterDashboards(dashboards, "the")).toHaveLength(0);
+ });
+
+ test("returns an empty array for a missing list", () => {
+ expect(filterDashboards(undefined, "x")).toEqual([]);
+ expect(filterDashboards(null, "x")).toEqual([]);
+ });
+});
diff --git a/reactapp/__tests__/components/views/LandingPage.test.js b/reactapp/__tests__/components/views/LandingPage.test.js
index 29da98be..4544283f 100644
--- a/reactapp/__tests__/components/views/LandingPage.test.js
+++ b/reactapp/__tests__/components/views/LandingPage.test.js
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import {
publicDashboard,
mockedDashboards,
@@ -121,6 +122,139 @@ describe("LandingPage", () => {
expect(screen.getAllByTitle("Public dashboard")).toHaveLength(1);
});
+ describe("search", () => {
+ // Purpose-built records: one description repeats "the" so a stopword-only
+ // query has something it could wrongly match, and neither name contains it.
+ const searchDashboards = [
+ {
+ ...JSON.parse(JSON.stringify(mockedDashboards.dashboards[0])),
+ id: 1,
+ name: "Flood Depth",
+ description: "Shows the flood depth across the basin",
+ },
+ {
+ ...JSON.parse(JSON.stringify(mockedDashboards.dashboards[1])),
+ id: 2,
+ name: "Reservoir Storage",
+ description: "Willamette reservoir storage and forecasts",
+ },
+ ];
+
+ const renderLandingPage = (availableDashboards = searchDashboards) =>
+ render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const searchInput = () => screen.getByLabelText("Dashboard Search Input");
+
+ it("shows every dashboard before anything is typed", () => {
+ renderLandingPage();
+
+ expect(screen.getByText("Flood Depth")).toBeInTheDocument();
+ expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
+ expect(screen.queryByText(/dashboards$/)).not.toBeInTheDocument();
+ });
+
+ it("filters on a partial name", async () => {
+ renderLandingPage();
+
+ await userEvent.type(searchInput(), "flood");
+
+ expect(screen.getByText("Flood Depth")).toBeInTheDocument();
+ expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();
+ expect(screen.getByText("1 of 2 dashboards")).toBeInTheDocument();
+ });
+
+ it("filters on a word that only appears in the description", async () => {
+ renderLandingPage();
+
+ await userEvent.type(searchInput(), "willamette");
+
+ expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
+ expect(screen.queryByText("Flood Depth")).not.toBeInTheDocument();
+ });
+
+ it("ignores an insignificant word rather than matching descriptions on it", async () => {
+ // "the" appears twice in the Flood Depth description and in neither name.
+ renderLandingPage();
+
+ await userEvent.type(searchInput(), "the");
+
+ expect(screen.queryByText("Flood Depth")).not.toBeInTheDocument();
+ expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/No dashboards match/, { exact: false }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("0 of 2 dashboards")).toBeInTheDocument();
+ });
+
+ it("still matches significant words when stopwords are typed alongside", async () => {
+ renderLandingPage();
+
+ await userEvent.type(searchInput(), "the basin");
+
+ expect(screen.getByText("Flood Depth")).toBeInTheDocument();
+ expect(screen.getByText("1 of 2 dashboards")).toBeInTheDocument();
+ });
+
+ it("hides the New Dashboard tile while filtering and restores it after", async () => {
+ renderLandingPage();
+ expect(screen.getByText("Create a New Dashboard")).toBeInTheDocument();
+
+ await userEvent.type(searchInput(), "flood");
+ expect(
+ screen.queryByText("Create a New Dashboard"),
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByLabelText("Clear Dashboard Search"));
+ expect(screen.getByText("Create a New Dashboard")).toBeInTheDocument();
+ });
+
+ it("clearing the box restores every dashboard", async () => {
+ renderLandingPage();
+
+ await userEvent.type(searchInput(), "flood");
+ expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByLabelText("Clear Dashboard Search"));
+ expect(searchInput()).toHaveValue("");
+ expect(screen.getByText("Flood Depth")).toBeInTheDocument();
+ expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
+ });
+
+ it("omits the search box entirely when there is nothing to search", () => {
+ renderLandingPage([]);
+
+ expect(
+ screen.queryByLabelText("Dashboard Search Input"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
it("Doesn't show Create new Dashboard when signed in as public with no dashboards", () => {
render(
diff --git a/reactapp/components/landingPage/dashboardSearch.js b/reactapp/components/landingPage/dashboardSearch.js
new file mode 100644
index 00000000..ae89e281
--- /dev/null
+++ b/reactapp/components/landingPage/dashboardSearch.js
@@ -0,0 +1,101 @@
+/**
+ * Landing-page dashboard filtering.
+ *
+ * Names and descriptions are matched differently on purpose:
+ *
+ * - A name is matched as a plain substring of the whole query, stopwords and
+ * all, because a dashboard called "The Basin" has to be findable by typing
+ * "the".
+ * - A description is matched by its significant words only. Requiring "the" to
+ * appear would make it match nearly every dashboard in the app, which is
+ * noise rather than a filter.
+ *
+ * A query made up entirely of stopwords therefore contributes no description
+ * terms, and falls back to name matching alone.
+ */
+
+// Common English function words. Deliberately short: an aggressive list starts
+// discarding words people search on ("no data", "not started").
+export const STOPWORDS = new Set([
+ "a",
+ "an",
+ "and",
+ "are",
+ "as",
+ "at",
+ "be",
+ "but",
+ "by",
+ "for",
+ "from",
+ "if",
+ "in",
+ "into",
+ "is",
+ "it",
+ "of",
+ "on",
+ "or",
+ "than",
+ "that",
+ "the",
+ "then",
+ "this",
+ "to",
+ "was",
+ "were",
+ "with",
+]);
+
+/**
+ * Casefold and strip accents so "clasificacion" finds "Clasificación".
+ *
+ * NFD splits an accented character into its base letter plus a combining mark,
+ * which the Diacritic property then removes.
+ */
+export function normalizeForSearch(value) {
+ if (typeof value !== "string") return "";
+ return value
+ .normalize("NFD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLowerCase();
+}
+
+/**
+ * The words of a query that are worth matching a description against.
+ *
+ * Splits on anything that is not a letter or a digit, so punctuation in either
+ * the query or the description ("Exercise #2", "flood-depth") does not prevent
+ * a match.
+ */
+export function significantTokens(query) {
+ return normalizeForSearch(query)
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((token) => token !== "" && !STOPWORDS.has(token));
+}
+
+/**
+ * True when a dashboard should stay visible for the given query.
+ *
+ * An empty or whitespace-only query matches everything, so clearing the box
+ * restores the full list.
+ */
+export function matchesDashboardSearch({ name, description }, query) {
+ const normalizedQuery = normalizeForSearch(query).trim();
+ if (normalizedQuery === "") return true;
+
+ if (normalizeForSearch(name).includes(normalizedQuery)) return true;
+
+ const tokens = significantTokens(query);
+ if (tokens.length === 0) return false;
+
+ const normalizedDescription = normalizeForSearch(description);
+ return tokens.every((token) => normalizedDescription.includes(token));
+}
+
+/** Dashboards matching the query, in their original order. */
+export function filterDashboards(dashboards, query) {
+ return (dashboards ?? []).filter((dashboard) =>
+ matchesDashboardSearch(dashboard, query),
+ );
+}
diff --git a/reactapp/views/LandingPage.js b/reactapp/views/LandingPage.js
index 337a60c8..99f6c2dc 100644
--- a/reactapp/views/LandingPage.js
+++ b/reactapp/views/LandingPage.js
@@ -1,4 +1,4 @@
-import { useContext } from "react";
+import { useContext, useMemo, useState } from "react";
import { LandingPageHeader } from "components/layout/Header";
import {
AppContext,
@@ -10,10 +10,15 @@ import DashboardCard, {
NewDashboardCard,
NoDashboardCard,
} from "components/landingPage/DashboardCard";
+import { filterDashboards } from "components/landingPage/dashboardSearch";
import styled from "styled-components";
import Container from "react-bootstrap/Container";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
+import Button from "react-bootstrap/Button";
+import FormControl from "react-bootstrap/FormControl";
+import InputGroup from "react-bootstrap/InputGroup";
+import { BsSearch, BsXLg } from "react-icons/bs";
const StyledContainer = styled(Container)`
margin-top: 1rem;
@@ -28,27 +33,96 @@ const StyledCol = styled(Col)`
width: auto;
`;
+const SearchRow = styled(Row)`
+ justify-content: center;
+ margin-bottom: 1rem;
+`;
+
+const SearchCol = styled(Col)`
+ max-width: 32rem;
+`;
+
+const ResultSummary = styled.p`
+ margin: 0.4rem 0 0 0;
+ font-size: 0.85rem;
+ color: #6c757d;
+ text-align: center;
+`;
+
+const NoMatchesDiv = styled.div`
+ padding: 2rem 1rem;
+ text-align: center;
+ color: #6c757d;
+`;
+
const LandingPage = () => {
const { availableDashboards } = useContext(AvailableDashboardsContext);
const { user } = useContext(AppContext);
+ const [search, setSearch] = useState("");
+
+ const isSearching = search.trim() !== "";
+ const visibleDashboards = useMemo(
+ () => filterDashboards(availableDashboards, search),
+ [availableDashboards, search],
+ );
return (
+ {availableDashboards.length > 0 && (
+
+
+
+ setSearch(e.target.value)}
+ aria-label="Dashboard Search Input"
+ placeholder="Search by name or description"
+ />
+ {isSearching ? (
+
+ ) : (
+
+
+
+ )}
+
+ {isSearching && (
+
+ {visibleDashboards.length} of {availableDashboards.length}{" "}
+ dashboards
+
+ )}
+
+
+ )}
- {user?.username && (
+ {/* Hidden while filtering: it is not a dashboard, so leaving it in a
+ filtered set reads as a match. */}
+ {user?.username && !isSearching && (
)}
- {availableDashboards.length > 0 &&
- availableDashboards.map((dashboardMetadata) => (
-
-
-
- ))}
+ {visibleDashboards.map((dashboardMetadata) => (
+
+
+
+ ))}
+ {isSearching && visibleDashboards.length === 0 && (
+
+ No dashboards match “{search.trim()}”
+
+ )}
{!user?.username && availableDashboards.length === 0 && (