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 && (