From dbdd29bd52fb9108ef4925720d7010ae203404b8 Mon Sep 17 00:00:00 2001 From: Corey Krewson Date: Mon, 17 Aug 2026 12:24:21 -0700 Subject: [PATCH] Add a dashboard search box to the landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters the landing page by dashboard name and description as you type. Names and descriptions are matched differently on purpose. A name is matched as a plain substring of the whole query, stopwords included, so "guat" finds "Guatemala Hands On 1" and a dashboard actually called "The Basin" stays findable by typing "the". A description is matched by its significant words only, with common English function words dropped from the query. Requiring "the" to appear would surface nearly every dashboard in the app, which is noise rather than a filter. A query made up entirely of stopwords therefore leaves no description terms and falls back to name matching alone -- the case that makes the feature worth having. Two supporting choices: query tokens match inside longer words, so "flood" finds "flooding"; and both sides are NFD-normalized before comparison, so "clasificacion" finds "Clasificación". The matcher lives in its own module and is pure, so it is tested directly rather than only through the rendered page. The box hides itself when there are no dashboards to search, reports "n of m dashboards" while filtering, offers a clear button, and shows a no-matches message instead of an empty page. The New Dashboard tile is hidden while a query is active, since it is not a dashboard and would read as a match. No backend change: name and description are already in the dashboard metadata. Co-Authored-By: Claude Opus 5 --- .../landingPage/dashboardSearch.test.js | 174 ++++++++++++++++++ .../components/views/LandingPage.test.js | 134 ++++++++++++++ .../components/landingPage/dashboardSearch.js | 101 ++++++++++ reactapp/views/LandingPage.js | 90 ++++++++- 4 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 reactapp/__tests__/components/landingPage/dashboardSearch.test.js create mode 100644 reactapp/components/landingPage/dashboardSearch.js 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 && (