From 96db414ca17c8a86e8146251b1ede4df68967c90 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 5 Jun 2022 14:06:08 -0500 Subject: [PATCH] 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 --- web/components/checkbox.tsx | 31 ++++ web/components/contract-search.tsx | 176 +++++++++------------- web/components/feed/category-selector.tsx | 88 ++++++++++- web/hooks/use-event.ts | 20 +++ 4 files changed, 206 insertions(+), 109 deletions(-) create mode 100644 web/components/checkbox.tsx create mode 100644 web/hooks/use-event.ts diff --git a/web/components/checkbox.tsx b/web/components/checkbox.tsx new file mode 100644 index 00000000..df24ab6b --- /dev/null +++ b/web/components/checkbox.tsx @@ -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 ( +
+
+
+ toggle(!e.target.checked)} + /> +
+
+ +
+
+
+ ) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a39b349f..9495766f 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -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( querySortOptions?.defaultFilter ?? 'open' ) - const [category, setCategory] = useState('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 ( - + + {showCategorySelector && ( - + + setMode(c as 'categories' | 'following')} + /> + + {mode === 'categories' && user && ( + + )} + )} - {showFollows && (follows ?? []).length === 0 ? ( + {mode === 'following' && (follows ?? []).length === 0 ? ( <>You're not following anyone yet. ) : ( )} @@ -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]) -} diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx index 9ae5fd93..a3ed4777 100644 --- a/web/components/feed/category-selector.tsx +++ b/web/components/feed/category-selector.tsx @@ -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 (
) } + +export function EditCategoriesButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + + return ( +
setIsOpen(true)} + > + + +
+ ) +} + +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 ( + + + + {CATEGORY_LIST.map((cat) => ( + { + updateUser(user.id, { + followedCategories: checked + ? difference(followedCategories, [cat]) + : union([cat], followedCategories), + }) + }} + /> + ))} + + + + + ) +} diff --git a/web/hooks/use-event.ts b/web/hooks/use-event.ts new file mode 100644 index 00000000..443e1095 --- /dev/null +++ b/web/hooks/use-event.ts @@ -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(callback?: T) { + const ref = useRef(() => { + throw new Error('Cannot call an event handler while rendering.') + }) + useLayoutEffect(() => { + ref.current = callback + }) + return useCallback( + (...args) => ref.current?.apply(null, args), + [] + ) as T +}