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 */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import algoliasearch from 'algoliasearch/lite'
|
import algoliasearch from 'algoliasearch/lite'
|
||||||
import {
|
import {
|
||||||
|
Configure,
|
||||||
InstantSearch,
|
InstantSearch,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
SortBy,
|
SortBy,
|
||||||
useCurrentRefinements,
|
|
||||||
useInfiniteHits,
|
useInfiniteHits,
|
||||||
useRange,
|
|
||||||
useRefinementList,
|
|
||||||
useSortBy,
|
useSortBy,
|
||||||
} from 'react-instantsearch-hooks-web'
|
} from 'react-instantsearch-hooks-web'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import {
|
import {
|
||||||
Sort,
|
Sort,
|
||||||
|
@ -18,12 +17,13 @@ import {
|
||||||
} from '../hooks/use-sort-and-query-params'
|
} from '../hooks/use-sort-and-query-params'
|
||||||
import { ContractsGrid } from './contract/contracts-list'
|
import { ContractsGrid } from './contract/contracts-list'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ENV } from 'common/envs/constants'
|
import { ENV } from 'common/envs/constants'
|
||||||
import { CategorySelector } from './feed/category-selector'
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
|
import { ChoicesToggleGroup } from './choices-toggle-group'
|
||||||
|
import { EditCategoriesButton } from './feed/category-selector'
|
||||||
|
|
||||||
const searchClient = algoliasearch(
|
const searchClient = algoliasearch(
|
||||||
'GJQPAYENIF',
|
'GJQPAYENIF',
|
||||||
|
@ -53,7 +53,6 @@ export function ContractSearch(props: {
|
||||||
additionalFilter?: {
|
additionalFilter?: {
|
||||||
creatorId?: string
|
creatorId?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
category?: string
|
|
||||||
}
|
}
|
||||||
showCategorySelector: boolean
|
showCategorySelector: boolean
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
|
@ -66,6 +65,7 @@ export function ContractSearch(props: {
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
const followedCategories = user?.followedCategories
|
||||||
const follows = useFollows(user?.id)
|
const follows = useFollows(user?.id)
|
||||||
|
|
||||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
||||||
|
@ -74,40 +74,56 @@ export function ContractSearch(props: {
|
||||||
.map(({ value }) => value)
|
.map(({ value }) => value)
|
||||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
||||||
? initialSort
|
? initialSort
|
||||||
: querySortOptions?.defaultSort
|
: querySortOptions?.defaultSort ?? '24-hour-vol'
|
||||||
|
|
||||||
const [filter, setFilter] = useState<filter>(
|
const [filter, setFilter] = useState<filter>(
|
||||||
querySortOptions?.defaultFilter ?? 'open'
|
querySortOptions?.defaultFilter ?? 'open'
|
||||||
)
|
)
|
||||||
|
|
||||||
const [category, setCategory] = useState<string>('all')
|
const [mode, setMode] = useState<'categories' | 'following'>('categories')
|
||||||
const showFollows = category === 'following'
|
|
||||||
const followsKey =
|
|
||||||
showFollows && follows?.length ? `${follows.join(',')}` : ''
|
|
||||||
|
|
||||||
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}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstantSearch
|
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||||
searchClient={searchClient}
|
|
||||||
indexName={indexName}
|
|
||||||
key={`search-${
|
|
||||||
additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
|
|
||||||
}${followsKey}`}
|
|
||||||
initialUiState={
|
|
||||||
showFollows
|
|
||||||
? {
|
|
||||||
[indexName]: {
|
|
||||||
refinementList: {
|
|
||||||
creatorId: ['', ...(follows ?? [])],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<SearchBox
|
<SearchBox
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
@ -133,30 +149,40 @@ export function ContractSearch(props: {
|
||||||
select: '!select !select-bordered',
|
select: '!select !select-bordered',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Configure
|
||||||
|
facetFilters={filters}
|
||||||
|
numericFilters={numericFilters}
|
||||||
|
// Page resets on filters change.
|
||||||
|
page={0}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Spacer h={3} />
|
<Spacer h={3} />
|
||||||
|
|
||||||
{showCategorySelector && (
|
{showCategorySelector && (
|
||||||
<CategorySelector
|
<Row className="items-center gap-2">
|
||||||
className="mb-2"
|
<ChoicesToggleGroup
|
||||||
category={category}
|
currentChoice={mode}
|
||||||
setCategory={setCategory}
|
choicesMap={{
|
||||||
/>
|
[categoriesLabel]: 'categories',
|
||||||
|
[followingLabel]: 'following',
|
||||||
|
}}
|
||||||
|
setChoice={(c) => setMode(c as 'categories' | 'following')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'categories' && user && (
|
||||||
|
<EditCategoriesButton user={user} />
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
{showFollows && (follows ?? []).length === 0 ? (
|
{mode === 'following' && (follows ?? []).length === 0 ? (
|
||||||
<>You're not following anyone yet.</>
|
<>You're not following anyone yet.</>
|
||||||
) : (
|
) : (
|
||||||
<ContractSearchInner
|
<ContractSearchInner
|
||||||
querySortOptions={querySortOptions}
|
querySortOptions={querySortOptions}
|
||||||
filter={filter}
|
|
||||||
additionalFilter={{
|
|
||||||
category: category === 'following' ? 'all' : category,
|
|
||||||
...additionalFilter,
|
|
||||||
}}
|
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -169,15 +195,9 @@ export function ContractSearchInner(props: {
|
||||||
defaultSort: Sort
|
defaultSort: Sort
|
||||||
shouldLoadFromStorage?: boolean
|
shouldLoadFromStorage?: boolean
|
||||||
}
|
}
|
||||||
filter: filter
|
|
||||||
additionalFilter: {
|
|
||||||
creatorId?: string
|
|
||||||
tag?: string
|
|
||||||
category?: string
|
|
||||||
}
|
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
}) {
|
}) {
|
||||||
const { querySortOptions, filter, additionalFilter, onContractClick } = props
|
const { querySortOptions, onContractClick } = props
|
||||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||||
|
|
||||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||||
|
@ -209,23 +229,6 @@ export function ContractSearchInner(props: {
|
||||||
}
|
}
|
||||||
}, [index])
|
}, [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)
|
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => setIsInitialLoad(false), 1000)
|
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 clsx from 'clsx'
|
||||||
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
|
import { union, difference } from 'lodash'
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { CATEGORIES, CATEGORY_LIST } from '../../../common/categories'
|
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: {
|
export function CategorySelector(props: {
|
||||||
category: string
|
category: string
|
||||||
|
@ -54,12 +61,14 @@ function CategoryButton(props: {
|
||||||
category: string
|
category: string
|
||||||
isFollowed: boolean
|
isFollowed: boolean
|
||||||
toggle: () => void
|
toggle: () => void
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { toggle, category, isFollowed } = props
|
const { toggle, category, isFollowed, className } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
className,
|
||||||
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
|
||||||
'cursor-pointer select-none',
|
'cursor-pointer select-none',
|
||||||
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
|
||||||
|
@ -70,3 +79,80 @@ function CategoryButton(props: {
|
||||||
</div>
|
</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