diff --git a/components/search/HierarchicalMenuWidget.tsx b/components/search/HierarchicalMenuWidget.tsx new file mode 100644 index 000000000..85cfb53cb --- /dev/null +++ b/components/search/HierarchicalMenuWidget.tsx @@ -0,0 +1,449 @@ +import { useConnector } from "react-instantsearch" +import type { SearchResults } from "algoliasearch-helper" +import type { Connector } from "instantsearch.js" +import type { AdditionalWidgetProperties } from "react-instantsearch" +import { useCallback, useEffect, useMemo, useState } from "react" + +const cx = (...classNames: string[]): string => + classNames.filter(Boolean).join(" ") + +// Types + +type MultiselectHierarchicalMenuItem = SearchResults.FacetValue & { + label: string +} + +type MultiselectHierarchicalMenuLevel = { + attribute: string + items: MultiselectHierarchicalMenuItem[] + refine: (value: string) => void +} + +type MultiselectHierarchicalMenuRender = { + levels: MultiselectHierarchicalMenuLevel[] +} + +type MultiselectHierarchicalMenuState = { + levels: MultiselectHierarchicalMenuLevel[] + refinements: string[] +} + +type MultiselectHierarchicalMenuWidget = { + $$type: string + renderState: MultiselectHierarchicalMenuRender + indexRenderState: { + multiselectHierarchicalMenu: MultiselectHierarchicalMenuRender + } + indexUiState: { + multiselectHierarchicalMenu: MultiselectHierarchicalMenuRender + } +} + +export type MultiselectHierarchicalMenuParams = { + attributes: string[] + separator?: string +} + +// Connector + +export type MultiselectHierarchicalMenuConnector = Connector< + MultiselectHierarchicalMenuWidget, + MultiselectHierarchicalMenuParams +> + +export const connectMultiselectHierarchicalMenu: MultiselectHierarchicalMenuConnector = + (renderFn, unmountFn = () => {}) => { + return widgetParams => { + const { attributes, separator } = widgetParams + // Store information that needs to be shared across multiple method calls. + const connectorState: MultiselectHierarchicalMenuState = { + levels: [], + refinements: [] + } + + return { + $$type: "ais.multiselectHierarchicalMenu", + getWidgetRenderState({ results, helper }) { + // When there are no results, return the API with default values. + if (!results) return { levels: [], widgetParams } + + // Get the last refinement. + const lastRefinement = results.getRefinements().pop()?.attributeName + + // Merge the results items with the initial ones. + const getItems = ( + attribute: string, + isParent: boolean + ): MultiselectHierarchicalMenuItem[] => { + const sortByParameter = isParent ? ["name:asc"] : ["count:desc"] + + // Trigger a new search to apply the updated facets + if (!helper.state.disjunctiveFacets.includes(attribute)) { + helper.setQueryParameter("disjunctiveFacets", [ + ...helper.state.disjunctiveFacets, + attribute + ]) + helper.search() + } + + const facetValues = + (results?.getFacetValues(attribute, { + sortBy: sortByParameter + }) as SearchResults.FacetValue[]) || [] + + // Mapping over facetValues with an additional safety check + const resultsItems = + facetValues.length > 0 + ? facetValues.map(facetValue => ({ + ...facetValue, + label: facetValue.name + .split(separator || " > ") + .pop() as string, + count: facetValue.count + })) + : [] + if (lastRefinement && !attributes.includes(lastRefinement)) + return resultsItems + + const level = connectorState.levels.find( + level => level.attribute === attribute + ) + const levelItems = level?.items || [] + + if (!levelItems.length && resultsItems.length) return resultsItems + if (!resultsItems.length) return levelItems + + // Merge and sort items from results and existing state + const mergedItems = levelItems.map(levelItem => { + const resultsItem = resultsItems.find( + resultItem => resultItem.name === levelItem.name + ) + return resultsItem ? { ...levelItem, ...resultsItem } : levelItem + }) + + return mergedItems + } + + // Register refinements and items for each attribute. + for (let i = 0; i < attributes.length; i++) { + const attribute = attributes[i] + if (!connectorState.levels[i]) { + const refine = (value: string) => { + for (const attr of attributes) { + const isLastAttribute = + attribute === attributes[attributes.length - 1] && + attribute === attr + if ( + !isLastAttribute && + helper.getRefinements(attr).length > 0 + ) + helper.clearRefinements(attr) + } + const refinement = helper + .getRefinements(attribute) + .find(ref => ref.value === value) + + if (!refinement) { + helper.addDisjunctiveFacetRefinement(attribute, value) + } else { + helper.removeDisjunctiveFacetRefinement(attribute, value) + } + + helper.search() + } + + connectorState.levels[i] = { attribute, refine, items: [] } + } + + // Register the initial items. + if (results && !connectorState.levels[i].items.length) { + connectorState.levels[i].items = getItems(attribute, i === 0) + } + } + + // Call the getItems to get the updated items state. + const levels = connectorState.levels.map((level, i) => ({ + ...level, + items: getItems(level.attribute, i === 0) + })) + + return { levels, widgetParams } + }, + getRenderState(renderState, renderOptions) { + return { + ...renderState, + multiselectHierarchicalMenu: { + ...renderState.multiselectHierarchicalMenu, + ...this.getWidgetRenderState(renderOptions) + } + } + }, + init(initOptions) { + const { instantSearchInstance } = initOptions + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance + }, + true + ) + }, + render(renderOptions) { + const { instantSearchInstance } = renderOptions + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance + }, + false + ) + }, + dispose() { + unmountFn() + }, + getWidgetUiState(uiState, { searchParameters }) { + const state = attributes.reduce( + (levelState, attribute) => ({ + ...levelState, + [attribute]: + searchParameters.getDisjunctiveRefinements(attribute) || [] + }), + {} + ) + return { + ...uiState, + multiselectHierarchicalMenu: { + ...uiState.multiselectHierarchicalMenu, + levels: uiState.multiselectHierarchicalMenu?.levels || [], + ...state + } + } + }, + getWidgetSearchParameters(searchParameters, { uiState }) { + for (const attribute of attributes) { + const allTags = + (uiState.multiselectHierarchicalMenu?.[ + attribute as keyof MultiselectHierarchicalMenuRender + ] as unknown as string[]) || [] + + if (Array.isArray(allTags)) { + const currentRefinements = + searchParameters.disjunctiveFacetsRefinements[attribute] || [] + + const newTags = allTags.filter( + allTags => !currentRefinements.includes(allTags) + ) + + searchParameters.disjunctiveFacetsRefinements = { + ...searchParameters.disjunctiveFacetsRefinements, + [attribute]: [...currentRefinements, ...newTags] + } + } + } + + return searchParameters + } + } + } + } + +// Hook + +export const useMultiselectHierarchicalMenu = ( + props: MultiselectHierarchicalMenuParams, + additionalWidgetProperties?: AdditionalWidgetProperties +): MultiselectHierarchicalMenuState => { + return useConnector( + connectMultiselectHierarchicalMenu as any, + props, + additionalWidgetProperties + ) as MultiselectHierarchicalMenuState +} + +// Component + +type MultiselectHierarchicalMenuElementProps = { + levels: MultiselectHierarchicalMenuLevel[] + index?: number + item?: SearchResults.FacetValue & { label: string } +} + +const MultiselectHierarchicalMenuItem = ({ + levels, + index = 0, + item = { + name: "", + label: "", + escapedValue: "", + count: 0, + isRefined: false, + isExcluded: false + } +}: MultiselectHierarchicalMenuElementProps): JSX.Element => { + const subLevelItems = useMemo(() => { + const subLevel = levels[index + 1] + if (!subLevel) return [] + return subLevel.items.filter(subItem => subItem.name.startsWith(item.name)) + }, [levels, index, item]) + + const hasSubLevel: boolean = useMemo( + () => subLevelItems.length > 0, + [subLevelItems.length] + ) + + const isSubLevelRefined: boolean = useMemo( + () => subLevelItems.some(subItem => subItem.isRefined), + [subLevelItems] + ) + + const [isOpen, setIsOpen] = useState( + item.isRefined || isSubLevelRefined + ) + + const { refine }: MultiselectHierarchicalMenuLevel = useMemo( + () => levels[index], + [levels, index] + ) + + const onButtonClick = useCallback(() => { + if (isOpen) { + // Clear all refinements + levels.forEach(level => { + level.items.forEach(subItem => { + if (subItem.isRefined) { + level.refine(subItem.name) + } + }) + }) + } + setIsOpen(!isOpen) + }, [isOpen, levels]) + + const onLabelClick = useCallback(() => { + if (item.isRefined && isOpen && !isSubLevelRefined) { + setIsOpen(false) + refine(item.name) + return + } + setIsOpen(hasSubLevel || !isOpen) + refine(item.name) + }, [ + hasSubLevel, + item.isRefined, + item.name, + isOpen, + isSubLevelRefined, + refine, + onButtonClick + ]) + + useEffect(() => { + setIsOpen(item.isRefined || isSubLevelRefined) + }, [item.isRefined, isSubLevelRefined]) + + return ( +
  • + + {hasSubLevel && isOpen && ( + + )} +
  • + ) +} + +const MultiselectHierarchicalMenuList = ({ + levels, + index = 0, + item +}: MultiselectHierarchicalMenuElementProps): JSX.Element => { + const levelItems = useMemo( + () => + levels[index].items.filter( + levelItem => !item || levelItem.name.startsWith(item.name) + ), + [levels, index, item] + ) + + return ( + + ) +} + +export const MultiselectHierarchicalMenu = ({ + attributes, + separator = " > " +}: MultiselectHierarchicalMenuParams): JSX.Element => { + const { levels } = useMultiselectHierarchicalMenu({ attributes, separator }) + + return ( +
    + {levels.length > 0 && } +
    + ) +} diff --git a/components/search/SearchContainer.tsx b/components/search/SearchContainer.tsx index b128bf9e8..96c4fbb69 100644 --- a/components/search/SearchContainer.tsx +++ b/components/search/SearchContainer.tsx @@ -146,4 +146,105 @@ export const SearchContainer = styled.div` .ais-RefinementList-label { border-bottom: dashed 1px; } + + .ais-MultiselectHierarchicalMenu-list { + background-color: white; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1.5rem; + max-height: 250px; + overflow-y: auto; + list-style: none; + } + + .ais-MultiselectHierarchicalMenu-item { + font-size: 1rem; + border-bottom: dashed 1px; + } + + .ais-MultiselectHierarchicalMenu-label { + white-space: normal; + display: flex; + width: 100%; + align-items: center; + gap: 10px; + cursor: pointer; + } + + .ais-MultiselectHierarchicalMenu-count { + background: var(--bs-blue); + color: white; + font-size: 0.75rem; + line-height: 1rem; + padding-right: 10px; + padding-left: 10px; + border-radius: 10px; + border: none; + cursor: pointer; + } + .ais-MultiselectHierarchicalMenu-toggle { + font-size: 30px; + color: var(--bs-blue); + vertical-align: middle; + /* margin-bottom: 1rem; */ + background-color: transparent; + border: none; + padding: 0%; + cursor: pointer; + } + + .ais-MultiselectHierarchicalMenu-list--child { + display: inline-block; + overflow-y: visible; + margin: 0; + padding: 0 0 0 4px; + width: 100%; + list-style: none; + } + .ais-MultiselectHierarchicalMenu-checkbox--child { + box-shadow: none; + outline: 1px solid black; + border-radius: 1px; + color: var(--bs-blue); + margin-right: 6px; + cursor: pointer; + } + .ais-MultiselectHierarchicalMenu-item--selected + .ais-MultiselectHierarchicalMenu-label { + font-weight: bold; + } + .ais-MultiselectHierarchicalMenu-item--child--selected + .ais-MultiselectHierarchicalMenu-label--child { + font-weight: bold; + } + .ais-MultiselectHierarchicalMenu-item--child--selected + .ais-MultiselectHierarchicalMenu-checkbox--child { + background-image: url("/check-solid.svg"); + background-size: 0.75rem; + background-position: center; + background-repeat: no-repeat; + } + .ais-MultiselectHierarchicalMenu-item--child { + font-size: 1rem; + border-top: dashed 1px; + } + .ais-MultiselectHierarchicalMenu-label--child { + white-space: normal; + display: flex; + width: 100%; + align-items: center; + gap: 10px; + cursor: pointer; + } + .ais-MultiselectHierarchicalMenu-count--child { + background: var(--bs-blue); + color: white; + font-size: 0.75rem; + line-height: 1rem; + padding-right: 10px; + padding-left: 10px; + border-radius: 10px; + border: none; + cursor: pointer; + } ` diff --git a/components/search/bills/BillSearch.tsx b/components/search/bills/BillSearch.tsx index b224f467f..6a2daa34a 100644 --- a/components/search/bills/BillSearch.tsx +++ b/components/search/bills/BillSearch.tsx @@ -17,6 +17,7 @@ import { SearchErrorBoundary } from "../SearchErrorBoundary" import { useRouting } from "../useRouting" import { BillHit } from "./BillHit" import { useBillRefinements } from "./useBillRefinements" +import { useBillHierarchicalMenu } from "./useBillHierarchicalMenu" import { SortBy, SortByWithConfigurationItem } from "../SortBy" import { getServerConfig } from "../common" import { useBillSort } from "./useBillSort" @@ -30,6 +31,33 @@ const searchClient = new TypesenseInstantSearchAdapter({ } }).searchClient +const extractLastSegmentOfRefinements = (items: any[]) => { + return items.map(item => { + console.log(item) + if (item.label != "topics.lvl1") return item + const newRefinements = item.refinements.map( + (refinement: { label: string }) => { + // Split the label to extract the last part of the hierarchy + const lastPartOfLabel = refinement.label.includes(">") + ? refinement.label.split(" > ").pop() + : refinement.label + + return { + ...refinement, + // Update label to only show the last part + label: lastPartOfLabel + } + } + ) + + return { + ...item, + label: "Tags", + refinements: newRefinements + } + }) +} + export const BillSearch = () => { const items = useBillSort() const initialSortByValue = items[0].value @@ -75,6 +103,7 @@ const Layout: FC< React.PropsWithChildren<{ items: SortByWithConfigurationItem[] }> > = ({ items }) => { const refinements = useBillRefinements() + const hierarchicalMenu = useBillHierarchicalMenu() const status = useSearchStatus() return ( @@ -83,16 +112,21 @@ const Layout: FC< - {refinements.options} + + {hierarchicalMenu.options} + {refinements.options} + + {hierarchicalMenu.show} {refinements.show} {status === "empty" ? ( diff --git a/components/search/bills/useBillHierarchicalMenu.tsx b/components/search/bills/useBillHierarchicalMenu.tsx new file mode 100644 index 000000000..e2fbd7371 --- /dev/null +++ b/components/search/bills/useBillHierarchicalMenu.tsx @@ -0,0 +1,17 @@ +import { useHierarchicalMenu } from "../useHierarchicalMenu" + +export const useBillHierarchicalMenu = () => { + const baseProps = { limit: 500, searchable: true } + const propsList = [ + { + attribute: "topics.lvl0", + ...baseProps + }, + { + attribute: "topics.lvl1", + ...baseProps + } + ] + + return useHierarchicalMenu({ hierarchicalMenuProps: propsList }) +} diff --git a/components/search/testimony/TestimonySearch.tsx b/components/search/testimony/TestimonySearch.tsx index ccd900058..bfc4ff797 100644 --- a/components/search/testimony/TestimonySearch.tsx +++ b/components/search/testimony/TestimonySearch.tsx @@ -149,7 +149,9 @@ const Layout = () => { /> - {refinements.options} + + {refinements.options} + diff --git a/components/search/useHierarchicalMenu.tsx b/components/search/useHierarchicalMenu.tsx new file mode 100644 index 000000000..9ecc741b6 --- /dev/null +++ b/components/search/useHierarchicalMenu.tsx @@ -0,0 +1,69 @@ +import { useInstantSearch } from "react-instantsearch" +import { faFilter } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { useCallback, useState } from "react" +import styled from "styled-components" +import { useMediaQuery } from "usehooks-ts" +import { Button, Offcanvas } from "../bootstrap" +import { SearchContainer } from "./SearchContainer" +import { MultiselectHierarchicalMenu } from "./HierarchicalMenuWidget" +export const FilterButton = styled(Button)` + font-size: 1rem; + line-height: 1rem; + min-height: 2rem; + padding: 0.25rem 0.5rem 0.25rem 0.5rem; + align-self: flex-start; +` +const useHasRefinements = () => { + const { results } = useInstantSearch() + const refinements = results.getRefinements() + return refinements.length !== 0 +} + +export const useHierarchicalMenu = ({ + hierarchicalMenuProps +}: { + hierarchicalMenuProps: any[] +}) => { + const inline = useMediaQuery("(min-width: 768px)") + const [show, setShow] = useState(false) + const handleClose = useCallback(() => setShow(false), []) + const handleOpen = useCallback(() => setShow(true), []) + + const hierarchicalMenu = ( + <> + + + ) + const hasRefinements = useHasRefinements() + + return { + options: inline ? ( +
    {hierarchicalMenu}
    + ) : ( + + + Filter + + + {hierarchicalMenu} + + + ), + show: inline ? null : ( + + Filter + + ) + } +} diff --git a/components/search/useRefinements.tsx b/components/search/useRefinements.tsx index 762f4c70b..453e989ea 100644 --- a/components/search/useRefinements.tsx +++ b/components/search/useRefinements.tsx @@ -1,4 +1,9 @@ -import { RefinementList, useInstantSearch } from "react-instantsearch" +import { + HierarchicalMenu, + HierarchicalMenuProps, + RefinementList, + useInstantSearch +} from "react-instantsearch" import { faFilter } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { useCallback, useEffect, useState } from "react" @@ -14,7 +19,6 @@ export const FilterButton = styled(Button)` padding: 0.25rem 0.5rem 0.25rem 0.5rem; align-self: flex-start; ` - const useHasRefinements = () => { const { results } = useInstantSearch() const refinements = results.getRefinements() @@ -31,10 +35,6 @@ export const useRefinements = ({ const handleClose = useCallback(() => setShow(false), []) const handleOpen = useCallback(() => setShow(true), []) - useEffect(() => { - if (inline) setShow(false) - }, [inline]) - const refinements = ( <> {refinementProps.map((p, i) => ( @@ -46,9 +46,7 @@ export const useRefinements = ({ return { options: inline ? ( - - {refinements} - +
    {refinements}
    ) : ( diff --git a/package.json b/package.json index 335b224de..e81576ccd 100644 --- a/package.json +++ b/package.json @@ -172,9 +172,9 @@ "eslint": "^8.7.0", "eslint-config-next": "^14.0.4", "eslint-config-prettier": "^8.3.0", - "firebase-admin": "^10", "eslint-plugin-i18next": "^6.0.3", "eslint-plugin-jsx-a11y": "^6.9.0", + "firebase-admin": "^10", "firebase-tools": "^11.16.0", "ini": "^1.3.5", "inquirer": "^6.5.1",