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:
parent
0085ffcb0b
commit
69c49679f1
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user