From 52b482d9e531ae0f3758566a338c01b79a4b7c9d Mon Sep 17 00:00:00 2001 From: violet Date: Thu, 10 Sep 2026 15:18:42 -0400 Subject: [PATCH 1/3] search: sort Browse Bills by recency until a term is typed Relevance as the unconditional default ranked an empty search box by testimony volume, the sort's tiebreaker, which reads as MAPLE picking winners. Browse Bills now opens on "Most Recent Testimony"; typing a term moves the dropdown to Relevance and clearing it moves it back. A query like "liquor" expands through the legislative synonym set and matches hundreds of bills in their full text, so with a term Relevance leads with the strongest match instead of whichever weak body-text match has the newest testimony. The default index name stays the bare "bills" collection, with billsDefaultSort (latestTestimonyAt:desc) pinned as the adapter's sort_by: the adapter falls back to that whenever the index name has no sort segment. The bare key stays because links.tsx deep links and the routed URL's qs keys depend on it. Relevance is an ordinary "bills/sort/" option and the duplicate explicit most-recent option is gone. billsRelevanceSort and the eval-shared billsSearchParams are unchanged, so the harness sends what it did. SortBy gains an opt-in relevanceValue prop; hearings do not pass it and keep their chronological default. It switches only on the empty-to-term and term-to-empty transitions, and only away from the automatic choice, so a sort the user picked sticks. A URL that loads with a term and no sort lands on Relevance. A relevanceHelp prop renders a QuestionTooltip beside the dropdown saying what Relevance weighs, including that procedural Orders sort last and ties go to testimony count. Co-Authored-By: Claude Sonnet 5.5 --- components/links.tsx | 8 ++-- components/search/SortBy.tsx | 63 ++++++++++++++++++++----- components/search/bills/BillSearch.tsx | 27 ++++++++--- components/search/bills/useBillSort.tsx | 16 ++++++- components/search/searchParams.ts | 21 +++++---- public/locales/en/search.json | 1 + tests/e2e/browse-bills.spec.ts | 22 ++++++++- tests/e2e/page_objects/billPage.ts | 4 ++ 8 files changed, 129 insertions(+), 33 deletions(-) diff --git a/components/links.tsx b/components/links.tsx index 87e528046..41b92800e 100644 --- a/components/links.tsx +++ b/components/links.tsx @@ -4,6 +4,7 @@ import { BillTopic, CurrentCommittee } from "../functions/src/bills/types" import { Testimony } from "components/db/testimony" import { Bill, MemberContent } from "./db" import { formatBillId } from "./formatting" +import { billsDefaultIndex } from "./search/bills/useBillSort" import { TFunction } from "next-i18next" type LinkProps = PropsWithChildren< @@ -149,9 +150,10 @@ export const twitterShareLink = (publication: Testimony, t: TFunction) => { } export const billSearchByTopicLink = (court: number, topic: BillTopic) => { + // Routed uiState is keyed by the page's index name. const params = { - "bills/sort/latestTestimonyAt:desc[multiselectHierarchicalMenu][topics.lvl1][0]": `${topic.category} > ${topic.topic}`, - "bills/sort/latestTestimonyAt:desc[refinementList][court][0]": `${court}` + [`${billsDefaultIndex}[multiselectHierarchicalMenu][topics.lvl1][0]`]: `${topic.category} > ${topic.topic}`, + [`${billsDefaultIndex}[refinementList][court][0]`]: `${court}` } - return `/bills?${new URLSearchParams(params).toString()}` + return `${maple.billSearch()}?${new URLSearchParams(params).toString()}` } diff --git a/components/search/SortBy.tsx b/components/search/SortBy.tsx index 1e6df5f50..bba5b5a8a 100644 --- a/components/search/SortBy.tsx +++ b/components/search/SortBy.tsx @@ -1,6 +1,13 @@ -import { useSortBy, useConfigure, UseConfigureProps } from "react-instantsearch" +import { useEffect, useRef } from "react" +import { + useSortBy, + useConfigure, + useSearchBox, + UseConfigureProps +} from "react-instantsearch" import { SortByItem } from "instantsearch.js/es/connectors/sort-by/connectSortBy" import Select from "react-select" +import { QuestionTooltip } from "components/tooltip" import styled from "styled-components" const StyledSelect = styled(Select)` @@ -40,24 +47,56 @@ export type SortByWithConfigurationItem = SortByItem & { configure?: UseConfigureProps } -export const SortBy = ({ items }: { items: SortByWithConfigurationItem[] }) => { +export const SortBy = ({ + items, + relevanceValue, + relevanceHelp +}: { + items: SortByWithConfigurationItem[] + /** Opts the page into following the search box: the first item is the + * default (most recent) sort, and typing a term moves the dropdown to this + * value, clearing the term moves it back. */ + relevanceValue?: string + /** Tooltip text shown beside the dropdown saying what Relevance weighs. */ + relevanceHelp?: string +}) => { const sortBy = useSortBy({ items }), + { query } = useSearchBox(), + hadTerm = useRef(false), // A routed URL can restore a sort value that no longer exists — an option // renamed since the link was shared. connectSortBy passes it through with // only a dev-mode warning, so fall back to the default option instead of // crashing the page on `.configure` of undefined. selected = items.find(i => i.value === sortBy.currentRefinement) ?? items[0] useConfigure(selected.configure ?? {}) + + // Only the empty<->term transitions move the dropdown, and only away from + // the automatic choice, so a sort the user picked sticks. hadTerm starts + // false, so a URL that loads with a term but no sort lands on Relevance. + const hasTerm = query.trim() !== "" + useEffect(() => { + if (hasTerm === hadTerm.current) return + hadTerm.current = hasTerm + if (!relevanceValue) return + const current = sortBy.currentRefinement + if (hasTerm && current === items[0].value) sortBy.refine(relevanceValue) + else if (!hasTerm && current === relevanceValue) + sortBy.refine(items[0].value) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasTerm]) return ( - { - if (e) sortBy.refine(e.value) - }} - /> + + { + if (e) sortBy.refine(e.value) + }} + /> + {relevanceHelp && } + ) } diff --git a/components/search/bills/BillSearch.tsx b/components/search/bills/BillSearch.tsx index 6ee7bdfb8..d907c892c 100644 --- a/components/search/bills/BillSearch.tsx +++ b/components/search/bills/BillSearch.tsx @@ -23,14 +23,24 @@ import { BillHit } from "./BillHit" import { useBillRefinements } from "./useBillRefinements" import { SortBy, SortByWithConfigurationItem } from "../SortBy" import { getServerConfig, VirtualFilters } from "../common" -import { billsSearchParams } from "../searchParams" -import { useBillSort } from "./useBillSort" +import { billsDefaultSort, billsSearchParams } from "../searchParams" +import { + billsDefaultIndex, + billsRelevanceIndex, + useBillSort +} from "./useBillSort" import { FC, useState } from "react" import { pathToSearchState, searchStateToUrl } from "../routingHelpers" const searchClient = new TypesenseInstantSearchAdapter({ server: getServerConfig(), - additionalSearchParameters: billsSearchParams + // sort_by only reaches Typesense under the default option, whose index name + // (billsDefaultIndex) has no sort segment; every other option's + // "bills/sort/" index name overrides it in the adapter. + additionalSearchParameters: { + ...billsSearchParams, + sort_by: billsDefaultSort + } }).searchClient const extractLastSegmentOfRefinements = (items: any[]) => { @@ -61,13 +71,12 @@ const extractLastSegmentOfRefinements = (items: any[]) => { export const BillSearch = () => { const items = useBillSort() - const initialSortByValue = items[0].value return ( - + {refinements.show} ": the adapter + * only reads a sort from an index name that has a sort segment, and otherwise + * uses the sort_by BillSearch.tsx pins on the adapter. BillSearch.tsx keys the + * page on it and links.tsx keys deep links on it, and it is the qs key of the + * routed uiState. + */ +export const billsDefaultIndex = "bills" + +/** Relevance is an ordinary sort option; SortBy moves to it when a term is + * typed (see its relevanceValue prop). */ +export const billsRelevanceIndex = `bills/sort/${billsRelevanceSort}` export const useBillSort = () => { const now = useRef(new Date().getTime()) @@ -13,11 +25,11 @@ export const useBillSort = () => { () => [ { label: t("sort_by.most_recent_testimony"), - value: "bills/sort/latestTestimonyAt:desc" + value: billsDefaultIndex }, { label: t("sort_by.relevance"), - value: `bills/sort/${billsRelevanceSort}` + value: billsRelevanceIndex }, { label: t("sort_by.testimony_count"), diff --git a/components/search/searchParams.ts b/components/search/searchParams.ts index fa3206471..7a31b3f08 100644 --- a/components/search/searchParams.ts +++ b/components/search/searchParams.ts @@ -74,9 +74,10 @@ export const billsSearchParams = { exclude_fields: "body,numberVariants" } satisfies SearchParameters -/** The app's "Relevance" sort option (see useBillSort.tsx). The eval harness - * must use this sort; the UI's default latestTestimonyAt:desc sort does not - * measure text relevance. +/** The app's "Relevance" sort option for Browse Bills (see useBillSort.tsx), + * selected automatically while the search box has a term. The eval harness + * must use this sort; every other bills sort is a date or a count and + * does not measure text relevance. * * The `_eval` clause sorts every Order and Extension Order below every other * document. They are procedural — "Extension Order - Education", "Order @@ -98,15 +99,19 @@ export const billsSearchParams = { * legislationType refinement (useBillRefinements.tsx) is how a searcher who * wants them gets them back, which is why the facet ships with this sort. * - * Written as two `!=` clauses rather than the equivalent `!=[...]` list form: - * this string doubles as the InstantSearch index name, which travels through - * the URL as a qs key, and qs parses percent-encoded square brackets in a key - * as nesting — mangling the routed uiState so a reload or shared link loses - * the query, refinements and sort. No other character here is special to qs. + * Written as two `!=` clauses rather than the equivalent `!=[...]` list form. + * A sort embedded in an InstantSearch index name becomes a qs key in the URL, + * and qs parses percent-encoded square brackets in a key as nesting, mangling + * the routed uiState. This string rides in the index name, as do the hearings + * and testimony relevance sorts, so all three stay bracket-free. */ export const billsRelevanceSort = "_eval(legislationType:!=`Order` && legislationType:!=`Extension Order`):desc,_text_match:desc,testimonyCount:desc" +/** The sort Browse Bills runs while the search box is empty; BillSearch.tsx + * pins it as the adapter's sort_by. */ +export const billsDefaultSort = "latestTestimonyAt:desc" + export const testimonySearchParams = { ...weighted({ billId: 10, diff --git a/public/locales/en/search.json b/public/locales/en/search.json index 790d19775..7bdd0b48f 100644 --- a/public/locales/en/search.json +++ b/public/locales/en/search.json @@ -34,6 +34,7 @@ "newest": "Sort by Newest", "oldest": "Sort by Oldest", "relevance": "Sort by Relevance", + "relevance_help_bills": "Relevance ranks bills by how well they match your search, weighing the bill number most, then title, sponsor and committee, then the summary, cosponsors and bill text. Procedural Orders sort last, and ties go to the bill with more testimony.", "testimony_count": "Sort by Testimony Count", "cosponsor_count": "Sort by Cosponsor Count", "next_hearing_date": "Sort by Next Hearing Date", diff --git a/tests/e2e/browse-bills.spec.ts b/tests/e2e/browse-bills.spec.ts index a409e6075..a4d462c90 100644 --- a/tests/e2e/browse-bills.spec.ts +++ b/tests/e2e/browse-bills.spec.ts @@ -65,8 +65,8 @@ test.describe("Search result test", () => { }) // Array of sorting test configurations -// Need to add test for sort by relevant const sortingTests: string[] = [ + "Sort by Relevance", "Sort by Testimony Count", "Sort by Cosponsor Count", "Sort by Next Hearing Date", @@ -86,6 +86,26 @@ test.describe("Sort Bills test", () => { }) } + test("should follow the search box until a sort is picked", async ({ + page + }) => { + const billpage = new BillPage(page) + + await expect( + billpage.selectedSort("Sort by Most Recent Testimony") + ).toBeVisible() + await billpage.search(billpage.searchWord) + await expect(billpage.selectedSort("Sort by Relevance")).toBeVisible() + await billpage.searchBar.fill("") + await expect( + billpage.selectedSort("Sort by Most Recent Testimony") + ).toBeVisible() + + await billpage.sort("Sort by Testimony Count") + await billpage.search(billpage.searchWord) + await expect(billpage.selectedSort("Sort by Testimony Count")).toBeVisible() + }) + // Test sorting with an empty list test("should handle sorting with an empty list", async ({ page }) => { const billPage = new BillPage(page) diff --git a/tests/e2e/page_objects/billPage.ts b/tests/e2e/page_objects/billPage.ts index 62adcf24f..1c30cf0ce 100644 --- a/tests/e2e/page_objects/billPage.ts +++ b/tests/e2e/page_objects/billPage.ts @@ -44,6 +44,10 @@ export class BillPage { await activeQueryFilter.waitFor({ state: "visible", timeout: 50000 }) } + selectedSort(label: string) { + return this.page.getByText(label, { exact: true }) + } + async sort(option: string) { // Interact with the sorting dropdown await this.page.getByText("Sort by Most Recent Testimony").click() From 5d4dcd362e75efe557cf8bcb409a476b1c80657a Mon Sep 17 00:00:00 2001 From: violet Date: Thu, 10 Sep 2026 15:50:36 -0400 Subject: [PATCH 2/3] search: sort Browse Testimony by newest until a term is typed Same change as for bills, same mechanism. Browse Testimony opens on "Newest": the default index is the bare "publishedTestimony" collection with testimonyDefaultSort (publishedAt:desc) pinned as the adapter's sort_by. Relevance is an ordinary "publishedTestimony/sort/" option that SortBy selects while the search box has a term, and the duplicate explicit newest option is gone. Oldest keeps its sort in the index name. The tooltip says testimony relevance weighs the bill number, then the author, then the content, with ties broken by newest. Hearings keep their date-windowed default. The e2e sort specs gain a case per page: the default sort is selected on load, a term moves it to Relevance, clearing returns it, and a sort picked by hand survives typing. Both page objects get a selectedSort locator. Closes hyphacoop/maple#87 Verified: tsc is clean. The sort specs in browse-bills.spec.ts and testimony.spec.ts pass on chromium against the local stack, 11 tests including both new cases. Not verified: the rest of the browse-bills and testimony e2e suites. The behaviour in a browser against the dev search cluster, whose latestTestimonyAt is 0 so recency row order cannot be judged there. A reload of a URL carrying a term and the bare index key lands on Relevance, because the bare key is also what an explicit pick of the default option writes. Co-Authored-By: Claude Sonnet 5.5 --- components/search/searchParams.ts | 8 +++- .../search/testimony/TestimonySearch.tsx | 45 ++++++++++++++----- public/locales/en/search.json | 1 + tests/e2e/page_objects/testimony.ts | 5 ++- tests/e2e/testimony.spec.ts | 37 ++++++++++----- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/components/search/searchParams.ts b/components/search/searchParams.ts index 7a31b3f08..4e64bc69d 100644 --- a/components/search/searchParams.ts +++ b/components/search/searchParams.ts @@ -134,11 +134,15 @@ export const testimonySearchParams = { exclude_fields: "billIdVariants" } satisfies SearchParameters -/** The app's "Relevance" sort option (see useTestimonySort in - * testimony/TestimonySearch.tsx), and the sort the eval harness must use. +/** The app's "Relevance" sort option for Browse Testimony (see + * useTestimonySort in testimony/TestimonySearch.tsx), selected automatically + * while the search box has a term, and the sort the eval harness must use. */ export const testimonyRelevanceSort = "_text_match:desc,publishedAt:desc" +/** The sort Browse Testimony runs while the search box is empty. */ +export const testimonyDefaultSort = "publishedAt:desc" + export const hearingsSearchParams = { ...weighted({ billNumbers: 10, diff --git a/components/search/testimony/TestimonySearch.tsx b/components/search/testimony/TestimonySearch.tsx index 9c8e4e751..fcce00209 100644 --- a/components/search/testimony/TestimonySearch.tsx +++ b/components/search/testimony/TestimonySearch.tsx @@ -25,33 +25,53 @@ import { SearchContainer } from "../SearchContainer" import { SearchErrorBoundary } from "../SearchErrorBoundary" import { SortBy } from "../SortBy" import { getServerConfig, VirtualFilters } from "../common" -import { testimonyRelevanceSort, testimonySearchParams } from "../searchParams" +import { + testimonyDefaultSort, + testimonyRelevanceSort, + testimonySearchParams +} from "../searchParams" import { TestimonyHit } from "./TestimonyHit" import { useTestimonyRefinements } from "./useTestimonyRefinements" import { FollowContext, OrgFollowStatus } from "components/shared/FollowContext" import { pathToSearchState, searchStateToUrl } from "../routingHelpers" import { useTranslation } from "next-i18next" +/** The InstantSearch index name of the default (Newest) sort: the bare + * collection, with the sort pinned on the adapter below, the same way Browse + * Bills does it (see billsDefaultIndex in bills/useBillSort.tsx). + */ +export const testimonyDefaultIndex = "publishedTestimony" + const searchClient = new TypesenseInstantSearchAdapter({ server: getServerConfig(), - additionalSearchParameters: testimonySearchParams + // sort_by only reaches Typesense under the default option, whose index name + // has no sort segment; the other options' "publishedTestimony/sort/" + // index names override it in the adapter. + additionalSearchParameters: { + ...testimonySearchParams, + sort_by: testimonyDefaultSort + } }).searchClient +/** Relevance is an ordinary sort option; SortBy moves to it when a term is + * typed (see its relevanceValue prop). */ +export const testimonyRelevanceIndex = `publishedTestimony/sort/${testimonyRelevanceSort}` + export const useTestimonySort = () => { const { t } = useTranslation("search") const items: SortByItem[] = useMemo( () => [ { label: t("sort_by.newest"), - value: "publishedTestimony/sort/publishedAt:desc" + value: testimonyDefaultIndex }, { - label: t("sort_by.oldest"), - value: "publishedTestimony/sort/publishedAt:asc" + label: t("sort_by.relevance"), + value: testimonyRelevanceIndex }, { - label: t("sort_by.relevance"), - value: `publishedTestimony/sort/${testimonyRelevanceSort}` + label: t("sort_by.oldest"), + value: "publishedTestimony/sort/publishedAt:asc" } ], [t] @@ -60,13 +80,12 @@ export const useTestimonySort = () => { } export const TestimonySearch = () => { - const initialSortByValue = useTestimonySort()[0].value return ( { - + {refinements.show} diff --git a/public/locales/en/search.json b/public/locales/en/search.json index 7bdd0b48f..cc3e6b1db 100644 --- a/public/locales/en/search.json +++ b/public/locales/en/search.json @@ -35,6 +35,7 @@ "oldest": "Sort by Oldest", "relevance": "Sort by Relevance", "relevance_help_bills": "Relevance ranks bills by how well they match your search, weighing the bill number most, then title, sponsor and committee, then the summary, cosponsors and bill text. Procedural Orders sort last, and ties go to the bill with more testimony.", + "relevance_help_testimony": "Relevance ranks testimony by how well it matches your search, weighing the bill number most, then the author, then the testimony text. Ties go to the newest.", "testimony_count": "Sort by Testimony Count", "cosponsor_count": "Sort by Cosponsor Count", "next_hearing_date": "Sort by Next Hearing Date", diff --git a/tests/e2e/page_objects/testimony.ts b/tests/e2e/page_objects/testimony.ts index 02baca3f1..d8c31d5ca 100644 --- a/tests/e2e/page_objects/testimony.ts +++ b/tests/e2e/page_objects/testimony.ts @@ -37,8 +37,11 @@ export class TestimonyPage { await this.page.getByRole("tab", { name: role }).click() } + selectedSort(label: string) { + return this.page.getByText(label, { exact: true }) + } + async sort(option: string) { - // previoud code: await this.page.getByText("Sort by New -> Old").click() await this.page .getByText(/Sort by/i) .first() diff --git a/tests/e2e/testimony.spec.ts b/tests/e2e/testimony.spec.ts index 94a1c56ea..94b8cf3ad 100644 --- a/tests/e2e/testimony.spec.ts +++ b/tests/e2e/testimony.spec.ts @@ -163,18 +163,35 @@ test.describe("Testimony Filtering", () => { }) }) +const sortingTests: string[] = [ + "Sort by Relevance", + "Sort by Newest", + "Sort by Oldest" +] + test.describe("Testimony Sorting", () => { - test("should sort by new -> old", async ({ page }) => { + for (const option of sortingTests) { + test(`should sort testimony by ${option}`, async ({ page }) => { + const testimonyPage = new TestimonyPage(page) + await testimonyPage.sort(option) + const sortValue = page.getByText(option, { exact: true }) + await expect(sortValue).toBeVisible() + }) + } + + test("should follow the search box until a sort is picked", async ({ + page + }) => { const testimonyPage = new TestimonyPage(page) - await testimonyPage.sort("Sort by New -> Old") - const sortValue = page.getByText("Sort by New -> Old", { exact: true }) - await expect(sortValue).toBeVisible() - }) - test("should sort by old -> new", async ({ page }) => { - const testimonyPage = new TestimonyPage(page) - await testimonyPage.sort("Sort by Old -> New") - const sortValue = page.getByText("Sort by Old -> New", { exact: true }) - await expect(sortValue).toBeVisible() + await expect(testimonyPage.selectedSort("Sort by Newest")).toBeVisible() + await testimonyPage.search("health") + await expect(testimonyPage.selectedSort("Sort by Relevance")).toBeVisible() + await testimonyPage.search("") + await expect(testimonyPage.selectedSort("Sort by Newest")).toBeVisible() + + await testimonyPage.sort("Sort by Oldest") + await testimonyPage.search("health") + await expect(testimonyPage.selectedSort("Sort by Oldest")).toBeVisible() }) }) From 56418a819da8e7b848209bc310d62b45a2bfae27 Mon Sep 17 00:00:00 2001 From: violet Date: Wed, 21 Oct 2026 10:48:00 -0400 Subject: [PATCH 3/3] search: show the Relevance tooltip only while Relevance is selected The tooltip explains what Relevance weighs, so it only helps when that sort is the one in use. SortBy now renders it when the selected item is the relevance option, which also covers a stale URL sort falling back to the default. Browse Bills and Browse Testimony both get this through the shared component; hearings pass no relevanceHelp and are unchanged. Verified: tsc reports nothing in SortBy.tsx. Not verified: the tooltip in a browser. No e2e spec asserts it, so none were run. Co-Authored-By: Claude Sonnet 5.5 --- components/search/SortBy.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/search/SortBy.tsx b/components/search/SortBy.tsx index bba5b5a8a..c70c92c31 100644 --- a/components/search/SortBy.tsx +++ b/components/search/SortBy.tsx @@ -96,7 +96,9 @@ export const SortBy = ({ if (e) sortBy.refine(e.value) }} /> - {relevanceHelp && } + {relevanceHelp && selected.value === relevanceValue && ( + + )} ) }