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:
James Grugett 2022-06-05 14:06:08 -05:00 committed by GitHub
parent 3d31641050
commit 96db414ca1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 206 additions and 109 deletions

View 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>
)
}

View File

@ -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])
}

View File

@ -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
View 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
}