Move search controls into separate component (#757)

* Move search controls into separate component

* Fix up typing on pill groups thingy

* More precise comparison per James

* Make sure `additionalFilter` is passed into controls
This commit is contained in:
Marshall Polaris 2022-08-13 16:34:03 -07:00 committed by GitHub
parent 0085ffcb0b
commit 69c49679f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite' import algoliasearch, { SearchIndex } from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
@ -12,6 +13,7 @@ import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
} from './contract/contracts-grid' } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useRef, useMemo, useState } from 'react' import { useEffect, useRef, useMemo, useState } from 'react'
import { unstable_batchedUpdates } from 'react-dom' import { unstable_batchedUpdates } from 'react-dom'
@ -20,7 +22,7 @@ import { useFollows } from 'web/hooks/use-follows'
import { track, trackCallback } from 'web/lib/service/analytics' import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
@ -49,15 +51,25 @@ export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = {
index: SearchIndex
query: string
numericFilters: SearchOptions['numericFilters']
facetFilters: SearchOptions['facetFilters']
showTime?: ShowTime
}
type AdditionalFilter = {
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
export function ContractSearch(props: { export function ContractSearch(props: {
user?: User | null user?: User | null
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
additionalFilter?: { additionalFilter?: AdditionalFilter
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
@ -80,6 +92,106 @@ export function ContractSearch(props: {
headerClassName, headerClassName,
} = props } = props
const [numPages, setNumPages] = useState(1)
const [pages, setPages] = useState<Contract[][]>([])
const [showTime, setShowTime] = useState<ShowTime | undefined>()
const searchParameters = useRef<SearchParameters | undefined>()
const requestId = useRef(0)
const performQuery = async (freshQuery?: boolean) => {
if (searchParameters.current === undefined) {
return
}
const params = searchParameters.current
const id = ++requestId.current
const requestedPage = freshQuery ? 0 : pages.length
if (freshQuery || requestedPage < numPages) {
const results = await params.index.search(params.query, {
facetFilters: params.facetFilters,
numericFilters: params.numericFilters,
page: requestedPage,
hitsPerPage: 20,
})
// if there's a more recent request, forget about this one
if (id === requestId.current) {
const newPage = results.hits as any as Contract[]
// this spooky looking function is the easiest way to get react to
// batch this and not do multiple renders. we can throw it out in react 18.
// see https://github.com/reactwg/react-18/discussions/21
unstable_batchedUpdates(() => {
setShowTime(params.showTime)
setNumPages(results.nbPages)
if (freshQuery) {
setPages([newPage])
} else {
setPages((pages) => [...pages, newPage])
}
})
}
}
}
const contracts = pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return (
<ContractSearchFirestore
querySortOptions={querySortOptions}
additionalFilter={additionalFilter}
/>
)
}
return (
<Col className="h-full">
<ContractSearchControls
className={headerClassName}
additionalFilter={additionalFilter}
hideOrderSelector={hideOrderSelector}
querySortOptions={querySortOptions}
user={user}
onSearchParametersChanged={(params) => {
searchParameters.current = params
performQuery(true)
}}
/>
<ContractsGrid
contracts={pages.length === 0 ? undefined : contracts}
loadMore={performQuery}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/>
</Col>
)
}
function ContractSearchControls(props: {
className?: string
additionalFilter?: AdditionalFilter
hideOrderSelector?: boolean
onSearchParametersChanged: (params: SearchParameters) => void
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
user?: User | null
}) {
const {
className,
additionalFilter,
hideOrderSelector,
onSearchParametersChanged,
querySortOptions,
user,
} = props
const { query, setQuery, sort, setSort } =
useQueryAndSortParams(querySortOptions)
const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter( const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug) (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
) )
@ -93,28 +205,15 @@ export function ContractSearch(props: {
(group) => group.contractIds.length (group) => group.contractIds.length
).reverse() ).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[] const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id)
const { query, setQuery, sort, setSort } =
useQueryAndSortParams(querySortOptions)
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' querySortOptions?.defaultFilter ?? 'open'
) )
const pillsEnabled = !additionalFilter && !query
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const selectPill = (pill: string | undefined) => () => {
setPillFilter(pill)
track('select search category', { category: pill ?? 'all' })
}
const additionalFilters = [ const additionalFilters = [
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
@ -160,57 +259,11 @@ export function ContractSearch(props: {
filter === 'closed' ? `closeTime <= ${Date.now()}` : '', filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f) ].filter((f) => f)
const indexName = `${indexPrefix}contracts-${sort}` const selectPill = (pill: string | undefined) => () => {
const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) setPillFilter(pill)
const searchIndex = useMemo( track('select search category', { category: pill ?? 'all' })
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const [numPages, setNumPages] = useState(1)
const [pages, setPages] = useState<Contract[][]>([])
const requestId = useRef(0)
const performQuery = async (freshQuery?: boolean) => {
const id = ++requestId.current
const requestedPage = freshQuery ? 0 : pages.length
if (freshQuery || requestedPage < numPages) {
const algoliaIndex = query ? searchIndex : index
const results = await algoliaIndex.search(query, {
facetFilters,
numericFilters,
page: requestedPage,
hitsPerPage: 20,
})
// if there's a more recent request, forget about this one
if (id === requestId.current) {
const newPage = results.hits as any as Contract[]
// this spooky looking function is the easiest way to get react to
// batch this and not do two renders. we can throw it out in react 18.
// see https://github.com/reactwg/react-18/discussions/21
unstable_batchedUpdates(() => {
setNumPages(results.nbPages)
if (freshQuery) {
setPages([newPage])
} else {
setPages((pages) => [...pages, newPage])
}
})
}
}
} }
useEffect(() => {
performQuery(true)
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
const contracts = pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
const updateQuery = (newQuery: string) => { const updateQuery = (newQuery: string) => {
setQuery(newQuery) setQuery(newQuery)
} }
@ -227,115 +280,103 @@ export function ContractSearch(props: {
track('select search sort', { sort: newSort }) track('select search sort', { sort: newSort })
} }
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { const indexName = `${indexPrefix}contracts-${sort}`
return ( const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
<ContractSearchFirestore const searchIndex = useMemo(
querySortOptions={querySortOptions} () => searchClient.initIndex(searchIndexName),
additionalFilter={additionalFilter} [searchIndexName]
/> )
)
} useEffect(() => {
onSearchParametersChanged({
index: query ? searchIndex : index,
query: query,
numericFilters: numericFilters,
facetFilters: facetFilters,
showTime:
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
})
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
return ( return (
<Col className="h-full"> <Col
<Col className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
className={clsx( >
'bg-base-200 sticky top-0 z-20 gap-3 pb-3', <Row className="gap-1 sm:gap-2">
headerClassName <input
)} type="text"
> value={query}
<Row className="gap-1 sm:gap-2"> onChange={(e) => updateQuery(e.target.value)}
<input onBlur={trackCallback('search', { query })}
type="text" placeholder={'Search'}
value={query} className="input input-bordered w-full"
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })}
placeholder={'Search'}
className="input input-bordered w-full"
/>
{!query && (
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
)}
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</Row>
{pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
</PillButton>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
</Col>
{filter === 'personal' &&
(follows ?? []).length === 0 &&
memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractsGrid
contracts={pages.length === 0 ? undefined : contracts}
loadMore={performQuery}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/> />
{!query && (
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
)}
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</Row>
{!additionalFilter && !query && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
</PillButton>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)} )}
</Col> </Col>
) )