From be01a152305a769c5b2859d1e2ca01c25e872524 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Jul 2022 19:08:51 -0500 Subject: [PATCH] Refactor search to not use Algolia components (#705) * In progress refactor to not use Algolia components * Cleanup * Only query when necessary * Read and update url params for query and sort * Don't push router * Don't update url if using default sort * Add popstate listener to improve home navigation * Remove console.logs --- web/components/contract-search.tsx | 366 +++++++++++------------- web/hooks/use-sort-and-query-params.tsx | 64 +++-- web/pages/home.tsx | 10 +- 3 files changed, 213 insertions(+), 227 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index c7660138..8596aa2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,26 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ import algoliasearch from 'algoliasearch/lite' -import { - Configure, - InstantSearch, - SearchBox, - SortBy, - useInfiniteHits, - useSortBy, -} from 'react-instantsearch-hooks-web' import { Contract } from 'common/contract' -import { - Sort, - useInitialQueryAndSort, - useUpdateQueryAndSort, -} from '../hooks/use-sort-and-query-params' +import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-list' import { Row } from './layout/row' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useUser } from 'web/hooks/use-user' @@ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { sortBy } from 'lodash' +import { range, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' +import { Col } from './layout/col' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -40,16 +29,15 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' -const sortIndexes = [ - { label: 'Newest', value: indexPrefix + 'contracts-newest' }, - // { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, - { label: 'Most popular', value: indexPrefix + 'contracts-score' }, - { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, - { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, - { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, - { label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, - { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, - { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, +const sortOptions = [ + { label: 'Newest', value: 'newest' }, + { label: 'Most popular', value: 'score' }, + { label: 'Most traded', value: 'most-traded' }, + { label: '24h volume', value: '24-hour-vol' }, + { label: 'Last updated', value: 'last-updated' }, + { label: 'Subsidy', value: 'liquidity' }, + { label: 'Close date', value: 'close-date' }, + { label: 'Resolve date', value: 'resolve-date' }, ] export const DEFAULT_SORT = 'score' @@ -108,13 +96,12 @@ export function ContractSearch(props: { memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups const follows = useFollows(user?.id) - const { initialSort } = useInitialQueryAndSort(querySortOptions) - const sort = sortIndexes - .map(({ value }) => value) - .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) - ? initialSort - : querySortOptions?.defaultSort ?? DEFAULT_SORT + const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort, + shouldLoadFromStorage, + }) const [filter, setFilter] = useState( querySortOptions?.defaultFilter ?? 'open' @@ -123,62 +110,129 @@ export function ContractSearch(props: { const [pillFilter, setPillFilter] = useState(undefined) - const selectFilter = (pill: string | undefined) => () => { + const selectPill = (pill: string | undefined) => () => { setPillFilter(pill) + setPage(0) track('select search category', { category: pill ?? 'all' }) } - const { filters, numericFilters } = useMemo(() => { - let filters = [ - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', - additionalFilter?.creatorId - ? `creatorId:${additionalFilter.creatorId}` - : '', - additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', - additionalFilter?.groupSlug - ? `groupLinks.slug:${additionalFilter.groupSlug}` - : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` - : '', - pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - // Show contracts bet on by users the user follows - .concat( - follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - ) - : '', - // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user - ? // Show contracts bet on by the user - `uniqueBettorIds:${user.id}` - : '', - ].filter((f) => f) - // Hack to make Algolia work. - filters = ['', ...filters] + let facetFilters = [ + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' + ? // Show contracts in groups that the user is a member of + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + ) + : '', + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', + ].filter((f) => f) + // Hack to make Algolia work. + facetFilters = ['', ...facetFilters] - const numericFilters = [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) - - return { filters, numericFilters } - }, [ - filter, - Object.values(additionalFilter ?? {}).join(','), - memberGroupSlugs.join(','), - (follows ?? []).join(','), - pillFilter, - ]) + const numericFilters = [ + filter === 'open' ? `closeTime > ${Date.now()}` : '', + filter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) const indexName = `${indexPrefix}contracts-${sort}` + const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) + + const [page, setPage] = useState(0) + const [numPages, setNumPages] = useState(1) + const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>( + {} + ) + + useEffect(() => { + let wasMostRecentQuery = true + index + .search(query, { + facetFilters, + numericFilters, + page, + hitsPerPage: 20, + }) + .then((results) => { + if (!wasMostRecentQuery) return + + if (page === 0) { + setHitsByPage({ + [0]: results.hits as any as Contract[], + }) + } else { + setHitsByPage((hitsByPage) => ({ + ...hitsByPage, + [page]: results.hits, + })) + } + setNumPages(results.nbPages) + }) + return () => { + wasMostRecentQuery = false + } + // Note numeric filters are unique based on current time, so can't compare + // them by value. + }, [query, page, index, JSON.stringify(facetFilters), filter]) + + const loadMore = () => { + if (page >= numPages - 1) return + + const haveLoadedCurrentPage = hitsByPage[page] + if (haveLoadedCurrentPage) setPage(page + 1) + } + + const hits = range(0, page + 1) + .map((p) => hitsByPage[p] ?? []) + .flat() + + const contracts = hits.filter( + (c) => !additionalFilter?.excludeContractIds?.includes(c.id) + ) + + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : undefined + + const updateQuery = (newQuery: string) => { + setQuery(newQuery) + setPage(0) + } + + const selectFilter = (newFilter: filter) => { + if (newFilter === filter) return + setFilter(newFilter) + setPage(0) + trackCallback('select search filter', { filter: newFilter }) + } + + const selectSort = (newSort: Sort) => { + if (newSort === sort) return + + setPage(0) + setSort(newSort) + track('select sort', { sort: newSort }) + } if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( @@ -190,23 +244,19 @@ export function ContractSearch(props: { } return ( - + - @@ -237,14 +285,14 @@ export function ContractSearch(props: { All {user ? 'For you' : 'Featured'} @@ -253,7 +301,7 @@ export function ContractSearch(props: { Your bets @@ -264,7 +312,7 @@ export function ContractSearch(props: { {name} @@ -280,103 +328,17 @@ export function ContractSearch(props: { memberGroupSlugs.length === 0 ? ( <>You're not following anyone, nor in any of your own groups yet. ) : ( - )} - - ) -} - -export function ContractSearchInner(props: { - querySortOptions?: { - defaultSort: Sort - shouldLoadFromStorage?: boolean - } - onContractClick?: (contract: Contract) => void - overrideGridClassName?: string - hideQuickBet?: boolean - excludeContractIds?: string[] - highlightOptions?: ContractHighlightOptions - cardHideOptions?: { - hideQuickBet?: boolean - hideGroupLink?: boolean - } -}) { - const { - querySortOptions, - onContractClick, - overrideGridClassName, - cardHideOptions, - excludeContractIds, - highlightOptions, - } = props - const { initialQuery } = useInitialQueryAndSort(querySortOptions) - - const { query, setQuery, setSort } = useUpdateQueryAndSort({ - shouldLoadFromStorage: true, - }) - - useEffect(() => { - setQuery(initialQuery) - }, [initialQuery]) - - const { currentRefinement: index } = useSortBy({ - items: [], - }) - - useEffect(() => { - setQuery(query) - }, [query]) - - const isFirstRender = useRef(true) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return - } - - const sort = index.split('contracts-')[1] as Sort - if (sort) { - setSort(sort) - } - }, [index]) - - const [isInitialLoad, setIsInitialLoad] = useState(true) - useEffect(() => { - const id = setTimeout(() => setIsInitialLoad(false), 1000) - return () => clearTimeout(id) - }, []) - - const { showMore, hits, isLastPage } = useInfiniteHits() - let contracts = hits as any as Contract[] - - if (isInitialLoad && contracts.length === 0) return <> - - const showTime = index.endsWith('close-date') - ? 'close-date' - : index.endsWith('resolve-date') - ? 'resolve-date' - : undefined - - if (excludeContractIds) - contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) - - return ( - + ) } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 9023dc1a..fb5bf29b 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,8 +1,6 @@ import { defaults, debounce } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' -import { useSearchBox } from 'react-instantsearch-hooks-web' -import { track } from 'web/lib/service/analytics' import { DEFAULT_SORT } from 'web/components/contract-search' const MARKETS_SORT = 'markets_sort' @@ -74,51 +72,69 @@ export function useInitialQueryAndSort(options?: { } } -export function useUpdateQueryAndSort(props: { - shouldLoadFromStorage: boolean +export function useQueryAndSortParams(options?: { + defaultSort?: Sort + shouldLoadFromStorage?: boolean }) { - const { shouldLoadFromStorage } = props + const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = + options ?? {} const router = useRouter() + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort + } + const setSort = (sort: Sort | undefined) => { - if (sort !== router.query.s) { - router.query.s = sort - router.replace({ query: { ...router.query, s: sort } }, undefined, { - shallow: true, - }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') - } + router.replace({ query: { ...router.query, s: sort } }, undefined, { + shallow: true, + }) + if (shouldLoadFromStorage) { + localStorage.setItem(MARKETS_SORT, sort || '') } } - const { query, refine } = useSearchBox() + const [queryState, setQueryState] = useState(query) + + useEffect(() => { + setQueryState(query) + }, [query]) // Debounce router query update. const pushQuery = useMemo( () => debounce((query: string | undefined) => { - if (query) { - router.query.q = query - } else { - delete router.query.q - } - router.replace({ query: router.query }, undefined, { + router.replace({ query: { ...router.query, q: query } }, undefined, { shallow: true, }) - track('search', { query }) - }, 500), + }, 100), [router] ) const setQuery = (query: string | undefined) => { - refine(query ?? '') + setQueryState(query) pushQuery(query) } + useEffect(() => { + // If there's no sort option, then set the one from localstorage + if (router.isReady && !sort && shouldLoadFromStorage) { + const localSort = localStorage.getItem(MARKETS_SORT) as Sort + if (localSort && localSort !== defaultSort) { + // Use replace to not break navigating back. + router.replace( + { query: { ...router.query, s: localSort } }, + undefined, + { shallow: true } + ) + } + } + }) + return { + sort: sort ?? defaultSort, + query: queryState ?? '', setSort, setQuery, - query, } } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 61003895..ab915ae3 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -81,11 +81,18 @@ const useContractPage = () => { if (!username || !contractSlug) setContract(undefined) else { // Show contract if route is to a contract: '/[username]/[contractSlug]'. - getContractFromSlug(contractSlug).then(setContract) + getContractFromSlug(contractSlug).then((contract) => { + const path = location.pathname.split('/').slice(1) + const [_username, contractSlug] = path + // Make sure we're still on the same contract. + if (contract?.slug === contractSlug) setContract(contract) + }) } } } + addEventListener('popstate', updateContract) + const { pushState, replaceState } = window.history window.history.pushState = function () { @@ -101,6 +108,7 @@ const useContractPage = () => { } return () => { + removeEventListener('popstate', updateContract) window.history.pushState = pushState window.history.replaceState = replaceState }