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..c70c92c31 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,58 @@ 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 && selected.value === relevanceValue && ( + + )} + ) } 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..4e64bc69d 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, @@ -129,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 790d19775..cc3e6b1db 100644 --- a/public/locales/en/search.json +++ b/public/locales/en/search.json @@ -34,6 +34,8 @@ "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.", + "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/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() 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() }) })