Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions components/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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()}`
}
65 changes: 53 additions & 12 deletions components/search/SortBy.tsx
Original file line number Diff line number Diff line change
@@ -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)`
Expand Down Expand Up @@ -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 (
<StyledSelect
classNamePrefix="s"
isSearchable={false}
isClearable={false}
value={selected}
options={sortBy.options}
onChange={(e: any) => {
if (e) sortBy.refine(e.value)
}}
/>
<span>
<StyledSelect
classNamePrefix="s"
isSearchable={false}
isClearable={false}
value={selected}
options={sortBy.options}
onChange={(e: any) => {
if (e) sortBy.refine(e.value)
}}
/>
{relevanceHelp && selected.value === relevanceValue && (
<QuestionTooltip text={relevanceHelp} />
)}
</span>
)
}
27 changes: 20 additions & 7 deletions components/search/bills/BillSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sort_by>" index name overrides it in the adapter.
additionalSearchParameters: {
...billsSearchParams,
sort_by: billsDefaultSort
}
}).searchClient

const extractLastSegmentOfRefinements = (items: any[]) => {
Expand Down Expand Up @@ -61,13 +71,12 @@ const extractLastSegmentOfRefinements = (items: any[]) => {

export const BillSearch = () => {
const items = useBillSort()
const initialSortByValue = items[0].value
return (
<SearchErrorBoundary>
<InstantSearch
indexName={initialSortByValue}
indexName={billsDefaultIndex}
initialUiState={{
[initialSortByValue]: {
[billsDefaultIndex]: {
refinementList: { court: [String(currentGeneralCourt)] }
}
}}
Expand Down Expand Up @@ -180,7 +189,11 @@ const Layout: FC<
<SearchBox placeholder="Search For Bills" />
<RefinementRow>
<ResultCount className="flex-grow-1" />
<SortBy items={items} />
<SortBy
items={items}
relevanceValue={billsRelevanceIndex}
relevanceHelp={t("sort_by.relevance_help_bills")}
/>
{refinements.show}
</RefinementRow>
<CurrentRefinements
Expand Down
16 changes: 14 additions & 2 deletions components/search/bills/useBillSort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { useMemo, useRef } from "react"
import { SortByWithConfigurationItem } from "../SortBy"
import { useTranslation } from "next-i18next"
import { billsRelevanceSort } from "../searchParams"
/** The InstantSearch index name of the default (Most Recent Testimony) sort.
* It is the bare collection rather than "bills/sort/<sort_by>": 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())
Expand All @@ -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"),
Expand Down
29 changes: 19 additions & 10 deletions components/search/searchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 34 additions & 11 deletions components/search/testimony/TestimonySearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sort_by>"
// 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]
Expand All @@ -60,13 +80,12 @@ export const useTestimonySort = () => {
}

export const TestimonySearch = () => {
const initialSortByValue = useTestimonySort()[0].value
return (
<SearchErrorBoundary>
<InstantSearch
indexName={initialSortByValue}
indexName={testimonyDefaultIndex}
initialUiState={{
[initialSortByValue]: {
[testimonyDefaultIndex]: {
refinementList: { court: [String(currentGeneralCourt)] }
}
}}
Expand Down Expand Up @@ -179,7 +198,11 @@ const Layout = () => {
<SearchBox placeholder="Search For Testimony" />
<RefinementRow>
<ResultCount className="flex-grow-1" />
<SortBy items={useTestimonySort()} />
<SortBy
items={useTestimonySort()}
relevanceValue={testimonyRelevanceIndex}
relevanceHelp={t("sort_by.relevance_help_testimony")}
/>
{refinements.show}
</RefinementRow>
<CurrentRefinements excludedAttributes={["authorRole"]} />
Expand Down
2 changes: 2 additions & 0 deletions public/locales/en/search.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion tests/e2e/browse-bills.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand Down
Loading
Loading