Category checklist (#426)
* Use ChoicesToggleGroup for categories vs following * Edit categories modal * Filter closed and resolved using Configure. Set page to 0. * Add useEvent hook, incase we want to use it before React releases it. * useMemo on filters computation * Try to fix prettier * Use check box! Add select all/none button
This commit is contained in:
parent
3d31641050
commit
96db414ca1
31
web/components/checkbox.tsx
Normal file
31
web/components/checkbox.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import clsx from 'clsx'
|
||||
|
||||
export function Checkbox(props: {
|
||||
label: string
|
||||
checked: boolean
|
||||
toggle: (checked: boolean) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { label, checked, toggle, className } = props
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'space-y-5')}>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
id={label}
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
checked={checked}
|
||||
onChange={(e) => toggle(!e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label htmlFor={label} className="font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,15 +1,14 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import algoliasearch from 'algoliasearch/lite'
|
||||
import {
|
||||
Configure,
|
||||
InstantSearch,
|
||||
SearchBox,
|
||||
SortBy,
|
||||
useCurrentRefinements,
|
||||
useInfiniteHits,
|
||||
useRange,
|
||||
useRefinementList,
|
||||
useSortBy,
|
||||
} from 'react-instantsearch-hooks-web'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import {
|
||||
Sort,
|
||||
|
@ -18,12 +17,13 @@ import {
|
|||
} from '../hooks/use-sort-and-query-params'
|
||||
import { ContractsGrid } from './contract/contracts-list'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ENV } from 'common/envs/constants'
|
||||
import { CategorySelector } from './feed/category-selector'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { ChoicesToggleGroup } from './choices-toggle-group'
|
||||
import { EditCategoriesButton } from './feed/category-selector'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -53,7 +53,6 @@ export function ContractSearch(props: {
|
|||
additionalFilter?: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
category?: string
|
||||
}
|
||||
showCategorySelector: boolean
|
||||
onContractClick?: (contract: Contract) => void
|
||||
|
@ -66,6 +65,7 @@ export function ContractSearch(props: {
|
|||
} = props
|
||||
|
||||
const user = useUser()
|
||||
const followedCategories = user?.followedCategories
|
||||
const follows = useFollows(user?.id)
|
||||
|
||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
||||
|
@ -74,40 +74,56 @@ export function ContractSearch(props: {
|
|||
.map(({ value }) => value)
|
||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
||||
? initialSort
|
||||
: querySortOptions?.defaultSort
|
||||
: querySortOptions?.defaultSort ?? '24-hour-vol'
|
||||
|
||||
const [filter, setFilter] = useState<filter>(
|
||||
querySortOptions?.defaultFilter ?? 'open'
|
||||
)
|
||||
|
||||
const [category, setCategory] = useState<string>('all')
|
||||
const showFollows = category === 'following'
|
||||
const followsKey =
|
||||
showFollows && follows?.length ? `${follows.join(',')}` : ''
|
||||
const [mode, setMode] = useState<'categories' | 'following'>('categories')
|
||||
|
||||
if (!sort) return <></>
|
||||
const { filters, numericFilters } = useMemo(() => {
|
||||
let filters = [
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
showCategorySelector
|
||||
? mode === 'categories'
|
||||
? followedCategories?.map((cat) => `lowercaseTags:${cat}`) ?? ''
|
||||
: follows?.map((creatorId) => `creatorId:${creatorId}`) ?? ''
|
||||
: '',
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
: '',
|
||||
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||
].filter((f) => f)
|
||||
// Hack to make Algolia work.
|
||||
filters = ['', ...filters]
|
||||
|
||||
const numericFilters = [
|
||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||
].filter((f) => f)
|
||||
|
||||
return { filters, numericFilters }
|
||||
}, [
|
||||
filter,
|
||||
showCategorySelector,
|
||||
mode,
|
||||
Object.values(additionalFilter ?? {}).join(','),
|
||||
followedCategories?.join(','),
|
||||
follows?.join(','),
|
||||
])
|
||||
|
||||
const categoriesLabel = `Categories ${
|
||||
followedCategories?.length ? followedCategories.length : '(All)'
|
||||
}`
|
||||
const followingLabel = `Following ${follows?.length ?? 0}`
|
||||
|
||||
const indexName = `${indexPrefix}contracts-${sort}`
|
||||
|
||||
return (
|
||||
<InstantSearch
|
||||
searchClient={searchClient}
|
||||
indexName={indexName}
|
||||
key={`search-${
|
||||
additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
|
||||
}${followsKey}`}
|
||||
initialUiState={
|
||||
showFollows
|
||||
? {
|
||||
[indexName]: {
|
||||
refinementList: {
|
||||
creatorId: ['', ...(follows ?? [])],
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<SearchBox
|
||||
className="flex-1"
|
||||
|
@ -133,30 +149,40 @@ export function ContractSearch(props: {
|
|||
select: '!select !select-bordered',
|
||||
}}
|
||||
/>
|
||||
<Configure
|
||||
facetFilters={filters}
|
||||
numericFilters={numericFilters}
|
||||
// Page resets on filters change.
|
||||
page={0}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Spacer h={3} />
|
||||
|
||||
{showCategorySelector && (
|
||||
<CategorySelector
|
||||
className="mb-2"
|
||||
category={category}
|
||||
setCategory={setCategory}
|
||||
/>
|
||||
<Row className="items-center gap-2">
|
||||
<ChoicesToggleGroup
|
||||
currentChoice={mode}
|
||||
choicesMap={{
|
||||
[categoriesLabel]: 'categories',
|
||||
[followingLabel]: 'following',
|
||||
}}
|
||||
setChoice={(c) => setMode(c as 'categories' | 'following')}
|
||||
/>
|
||||
|
||||
{mode === 'categories' && user && (
|
||||
<EditCategoriesButton user={user} />
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
{showFollows && (follows ?? []).length === 0 ? (
|
||||
{mode === 'following' && (follows ?? []).length === 0 ? (
|
||||
<>You're not following anyone yet.</>
|
||||
) : (
|
||||
<ContractSearchInner
|
||||
querySortOptions={querySortOptions}
|
||||
filter={filter}
|
||||
additionalFilter={{
|
||||
category: category === 'following' ? 'all' : category,
|
||||
...additionalFilter,
|
||||
}}
|
||||
onContractClick={onContractClick}
|
||||
/>
|
||||
)}
|
||||
|
@ -169,15 +195,9 @@ export function ContractSearchInner(props: {
|
|||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
filter: filter
|
||||
additionalFilter: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
category?: string
|
||||
}
|
||||
onContractClick?: (contract: Contract) => void
|
||||
}) {
|
||||
const { querySortOptions, filter, additionalFilter, onContractClick } = props
|
||||
const { querySortOptions, onContractClick } = props
|
||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||
|
@ -209,23 +229,6 @@ export function ContractSearchInner(props: {
|
|||
}
|
||||
}, [index])
|
||||
|
||||
const { creatorId, category, tag } = additionalFilter
|
||||
|
||||
useFilterCreator(creatorId)
|
||||
|
||||
useFilterTag(tag ?? (category === 'all' ? undefined : category))
|
||||
|
||||
useFilterClosed(
|
||||
filter === 'closed'
|
||||
? true
|
||||
: filter === 'all' || filter === 'resolved'
|
||||
? undefined
|
||||
: false
|
||||
)
|
||||
useFilterResolved(
|
||||
filter === 'resolved' ? true : filter === 'all' ? undefined : false
|
||||
)
|
||||
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setIsInitialLoad(false), 1000)
|
||||
|
@ -247,46 +250,3 @@ export function ContractSearchInner(props: {
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const useFilterCreator = (creatorId: string | undefined) => {
|
||||
const { refine } = useRefinementList({ attribute: 'creatorId' })
|
||||
useEffect(() => {
|
||||
if (creatorId) refine(creatorId)
|
||||
}, [creatorId, refine])
|
||||
}
|
||||
|
||||
const useFilterTag = (tag: string | undefined) => {
|
||||
const { items, refine: deleteRefinement } = useCurrentRefinements({
|
||||
includedAttributes: ['lowercaseTags'],
|
||||
})
|
||||
const { refine } = useRefinementList({ attribute: 'lowercaseTags' })
|
||||
useEffect(() => {
|
||||
const refinements = items[0]?.refinements ?? []
|
||||
if (tag) refine(tag.toLowerCase())
|
||||
if (refinements[0]) deleteRefinement(refinements[0])
|
||||
}, [tag])
|
||||
}
|
||||
|
||||
const useFilterClosed = (value: boolean | undefined) => {
|
||||
const [now] = useState(Date.now())
|
||||
useRange({
|
||||
attribute: 'closeTime',
|
||||
min: value === false ? now : undefined,
|
||||
max: value ? now : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const useFilterResolved = (value: boolean | undefined) => {
|
||||
const { items, refine: deleteRefinement } = useCurrentRefinements({
|
||||
includedAttributes: ['isResolved'],
|
||||
})
|
||||
|
||||
const { refine } = useRefinementList({ attribute: 'isResolved' })
|
||||
|
||||
useEffect(() => {
|
||||
const refinements = items[0]?.refinements ?? []
|
||||
|
||||
if (value !== undefined) refine(`${value}`)
|
||||
refinements.forEach((refinement) => deleteRefinement(refinement))
|
||||
}, [value])
|
||||
}
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
import clsx from 'clsx'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import { union, difference } from 'lodash'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { CATEGORIES, CATEGORY_LIST } from '../../../common/categories'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Col } from '../layout/col'
|
||||
import { useState } from 'react'
|
||||
import { updateUser, User } from 'web/lib/firebase/users'
|
||||
import { Checkbox } from '../checkbox'
|
||||
|
||||
export function CategorySelector(props: {
|
||||
category: string
|
||||
|
@ -54,12 +61,14 @@ function CategoryButton(props: {
|
|||
category: string
|
||||
isFollowed: boolean
|
||||
toggle: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const { toggle, category, isFollowed } = props
|
||||
const { toggle, category, isFollowed, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
||||
'cursor-pointer select-none',
|
||||
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
||||
|
@ -70,3 +79,80 @@ function CategoryButton(props: {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditCategoriesButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm text-gray-700'
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
<CategorySelectorModal
|
||||
user={user}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategorySelectorModal(props: {
|
||||
user: User
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}) {
|
||||
const { user, isOpen, setIsOpen } = props
|
||||
const followedCategories =
|
||||
user?.followedCategories === undefined
|
||||
? CATEGORY_LIST
|
||||
: user.followedCategories
|
||||
|
||||
const selectAll =
|
||||
user.followedCategories === undefined ||
|
||||
followedCategories.length < CATEGORY_LIST.length
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
<Col className="rounded bg-white p-6">
|
||||
<Col className="grid w-full grid-cols-2 gap-4">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Checkbox
|
||||
className="col-span-1"
|
||||
key={cat}
|
||||
label={CATEGORIES[cat].split(' ')[0]}
|
||||
checked={followedCategories.includes(cat)}
|
||||
toggle={(checked) => {
|
||||
updateUser(user.id, {
|
||||
followedCategories: checked
|
||||
? difference(followedCategories, [cat])
|
||||
: union([cat], followedCategories),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost mt-2 self-end"
|
||||
onClick={() => {
|
||||
if (selectAll) {
|
||||
updateUser(user.id, {
|
||||
followedCategories: CATEGORY_LIST,
|
||||
})
|
||||
} else {
|
||||
updateUser(user.id, {
|
||||
followedCategories: [],
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select {selectAll ? 'all' : 'none'}
|
||||
</button>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
20
web/hooks/use-event.ts
Normal file
20
web/hooks/use-event.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
// A hook soon to be added to the React core library:
|
||||
// https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
|
||||
// TODO: Once React adds this hook, use it instead.
|
||||
|
||||
import { useRef, useLayoutEffect, useCallback } from 'react'
|
||||
|
||||
type AnyFunction = (...args: any[]) => any
|
||||
|
||||
export function useEvent<T extends AnyFunction>(callback?: T) {
|
||||
const ref = useRef<AnyFunction | undefined>(() => {
|
||||
throw new Error('Cannot call an event handler while rendering.')
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
ref.current = callback
|
||||
})
|
||||
return useCallback<AnyFunction>(
|
||||
(...args) => ref.current?.apply(null, args),
|
||||
[]
|
||||
) as T
|
||||
}
|
Loading…
Reference in New Issue
Block a user