diff --git a/filters.config.js b/filters.config.js new file mode 100644 index 000000000..c053ded36 --- /dev/null +++ b/filters.config.js @@ -0,0 +1,46 @@ +module.exports = { + filters: [ + { + id: 'filterSet', + label: 'Categories', + type: 'filterList', + options: [ + { + label: 'automotive', + value: 'automotive' + }, + { + label: 'manufacturing', + value: 'manufacturing' + }, + { + label: 'text analysis', + value: 'textAnalysis' + }, + { + label: 'finance', + value: 'finance' + } + ] + } + ], + filterSets: { + automotive: [ + 'charging', + 'ev', + 'gx4m', + 'mobility', + 'moveid', + 'parking', + 'traffic' + ], + manufacturing: [ + 'euprogigant', + 'industry40', + 'manufacturing', + 'predictive-maintenance' + ], + textAnalysis: ['library', 'ocr', 'text-analysis'], + finance: ['graphql'] + } +} diff --git a/src/@context/Filter.tsx b/src/@context/Filter.tsx new file mode 100644 index 000000000..971898fe6 --- /dev/null +++ b/src/@context/Filter.tsx @@ -0,0 +1,66 @@ +import React, { + createContext, + useContext, + ReactElement, + ReactNode, + useState +} from 'react' +import { + SortDirectionOptions, + SortTermOptions +} from '../../src/@types/aquarius/SearchQuery' + +export interface Filters { + [key: string]: string[] +} + +export interface Sort { + sort: SortTermOptions + sortOrder: SortDirectionOptions +} + +interface FilterValue { + filters: Filters + setFilters: (filters: Filters) => void + ignorePurgatory: boolean + setIgnorePurgatory: (value: boolean) => void + sort: Sort + setSort: (sort: Sort) => void +} + +const FilterContext = createContext({} as FilterValue) + +function FilterProvider({ children }: { children: ReactNode }): ReactElement { + const [filters, setFilters] = useState({ + accessType: [], + serviceType: [], + filterSet: [] + }) + const [ignorePurgatory, setIgnorePurgatory] = useState(true) + const [sort, setSort] = useState({ + sort: SortTermOptions.Created, + sortOrder: SortDirectionOptions.Descending + }) + + return ( + + {children} + + ) +} + +// Helper hook to access the provider values +const useFilter = (): FilterValue => useContext(FilterContext) + +export { FilterProvider, useFilter } diff --git a/src/@hooks/useDebounce.tsx b/src/@hooks/useDebounce.tsx new file mode 100644 index 000000000..146b186d3 --- /dev/null +++ b/src/@hooks/useDebounce.tsx @@ -0,0 +1,12 @@ +import { useEffect, useCallback } from 'react' + +const useDebounce = (effect, dependencies: any[], delay: number) => { + const callback = useCallback(effect, dependencies) + + useEffect(() => { + const timeout = setTimeout(callback, delay) + return () => clearTimeout(timeout) + }, [callback, delay]) +} + +export default useDebounce diff --git a/src/@types/aquarius/SearchQuery.ts b/src/@types/aquarius/SearchQuery.ts index 9dbabc564..293483de1 100644 --- a/src/@types/aquarius/SearchQuery.ts +++ b/src/@types/aquarius/SearchQuery.ts @@ -16,6 +16,11 @@ export enum SortTermOptions { // Only export/import works for that, so this file is NOT .d.ts file ending // and gets imported in components. +export enum FilterOptions { + AccessType = 'accessType', + ServiceType = 'serviceType' +} + export enum FilterByTypeOptions { Data = 'dataset', Algorithm = 'algorithm' diff --git a/src/@utils/aquarius/index.test.ts b/src/@utils/aquarius/index.test.ts index 33b12a259..572c983f0 100644 --- a/src/@utils/aquarius/index.test.ts +++ b/src/@utils/aquarius/index.test.ts @@ -2,7 +2,12 @@ import { SortDirectionOptions, SortTermOptions } from '../../@types/aquarius/SearchQuery' -import { escapeEsReservedCharacters, getFilterTerm, generateBaseQuery } from '.' +import { + escapeEsReservedCharacters, + getFilterTerm, + generateBaseQuery, + getWhitelistShould +} from '.' const defaultBaseQueryReturn = { from: 0, @@ -20,7 +25,9 @@ const defaultBaseQueryReturn = { ] } } - ] + ], + should: [...getWhitelistShould()], + ...(getWhitelistShould().length > 0 ? { minimum_should_match: 1 } : []) } }, size: 1000 diff --git a/src/@utils/aquarius/index.ts b/src/@utils/aquarius/index.ts index 36a611fbf..2772c91db 100644 --- a/src/@utils/aquarius/index.ts +++ b/src/@utils/aquarius/index.ts @@ -10,6 +10,8 @@ import { import { transformAssetToAssetSelection } from '../assetConvertor' import addressConfig from '../../../address.config' import { isValidDid } from '@utils/ddo' +import { Filters } from '@context/Filter' +import { filterSets } from '@components/Search/Filter' export interface UserSales { id: string @@ -46,8 +48,37 @@ export function getFilterTerm( } } -export function getWhitelistShould(): // eslint-disable-next-line camelcase -{ should: FilterTerm[]; minimum_should_match: 1 } | undefined { +export function parseFilters( + filtersList: Filters, + filterSets: { [key: string]: string[] } +): FilterTerm[] { + const filterQueryPath = { + accessType: 'services.type', + serviceType: 'metadata.type', + filterSet: 'metadata.tags.keyword' + } + + const filterTerms = Object.keys(filtersList)?.map((key) => { + if (key === 'filterSet') { + const tags = filtersList[key].reduce( + (acc, set) => [...acc, ...filterSets[set]], + [] + ) + const uniqueTags = [...new Set(tags)] + return uniqueTags.length > 0 + ? getFilterTerm(filterQueryPath[key], uniqueTags) + : undefined + } + if (filtersList[key].length > 0) + return getFilterTerm(filterQueryPath[key], filtersList[key]) + + return undefined + }) + + return filterTerms.filter((term) => term !== undefined) +} + +export function getWhitelistShould(): FilterTerm[] { const { whitelists } = addressConfig const whitelistFilterTerms = Object.entries(whitelists) @@ -57,12 +88,7 @@ export function getWhitelistShould(): // eslint-disable-next-line camelcase ) .reduce((prev, cur) => prev.concat(cur), []) - return whitelistFilterTerms.length > 0 - ? { - should: whitelistFilterTerms, - minimum_should_match: 1 - } - : undefined + return whitelistFilterTerms.length > 0 ? whitelistFilterTerms : [] } export function getDynamicPricingMustNot(): // eslint-disable-next-line camelcase @@ -102,7 +128,7 @@ export function generateBaseQuery( } } ], - ...getWhitelistShould() + should: [...getWhitelistShould()] } } } as SearchQuery @@ -117,6 +143,11 @@ export function generateBaseQuery( baseQueryParams.sortOptions.sortDirection || SortDirectionOptions.Descending } + + if (generatedQuery.query?.bool?.should?.length > 0) { + generatedQuery.query.bool.minimum_should_match = 1 + } + return generatedQuery } @@ -289,9 +320,8 @@ export async function getPublishedAssets( cancelToken: CancelToken, ignorePurgatory = false, ignoreState = false, - page?: number, - type?: string, - accesType?: string + filtersList?: Filters, + page?: number ): Promise { if (!accountId) return @@ -299,9 +329,7 @@ export async function getPublishedAssets( filters.push(getFilterTerm('nft.state', [0, 4, 5])) filters.push(getFilterTerm('nft.owner', accountId.toLowerCase())) - accesType !== undefined && - filters.push(getFilterTerm('services.type', accesType)) - type !== undefined && filters.push(getFilterTerm('metadata.type', type)) + parseFilters(filtersList, filterSets).forEach((term) => filters.push(term)) const baseQueryParams = { chainIds, diff --git a/src/components/@shared/Accordion/index.module.css b/src/components/@shared/Accordion/index.module.css new file mode 100644 index 000000000..ae9e0c556 --- /dev/null +++ b/src/components/@shared/Accordion/index.module.css @@ -0,0 +1,62 @@ +.title, +.compactTitle { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + margin-bottom: 0; + color: var(--font-color-title); +} + +.title > span:first-child, +.compactTitle > span:first-child { + margin-right: calc(var(--spacer) / 4); +} + +.compactTitle { + font-size: var(--font-size-h5); +} + +.badge { + margin: 0; + background-color: var(--menu-border-color); + color: var(--menu-network-badge-font-color); + font-size: var(--font-size-base); + vertical-align: middle; +} + +.toggle { + margin-left: calc(var(--spacer) / 4); + transform: none !important; +} + +.toggle svg { + display: inline-block; + width: var(--font-size-base); + height: var(--font-size-base); + fill: var(--font-color-title); + vertical-align: middle; + transition: 0.2s ease-out; +} + +.open .toggle svg { + transform: rotate(180deg); +} + +.actions .content { + max-height: 0; + visibility: hidden; + overflow: hidden; + transition: 0.3s; +} + +.open .content { + max-height: 35rem; + visibility: visible; + overflow-y: scroll; + transition: 0.3s max-height; +} + +.open .compactContent { + max-height: 15rem; +} diff --git a/src/components/@shared/Accordion/index.tsx b/src/components/@shared/Accordion/index.tsx new file mode 100644 index 000000000..73eb879ee --- /dev/null +++ b/src/components/@shared/Accordion/index.tsx @@ -0,0 +1,56 @@ +import React, { ReactElement, ReactNode, useState } from 'react' +import Button from '@shared/atoms/Button' +import styles from './index.module.css' +import Caret from '@images/caret.svg' +import classNames from 'classnames/bind' +import Badge from '@shared/atoms/Badge' + +const cx = classNames.bind(styles) + +export default function Accordion({ + title, + defaultExpanded = false, + badgeNumber, + compact, + action, + children +}: { + title: string + defaultExpanded?: boolean + badgeNumber?: number + compact?: boolean + action?: ReactNode + children: ReactNode +}): ReactElement { + const [open, setOpen] = useState(!!defaultExpanded) + + async function handleClick() { + setOpen(!open) + } + + return ( +
+

+ {title} + {badgeNumber > 0 && ( + + )} + +

+ {action} +
+ {children} +
+
+ ) +} diff --git a/src/components/@shared/AssetList/index.tsx b/src/components/@shared/AssetList/index.tsx index 7507576d9..19a6ff06f 100644 --- a/src/components/@shared/AssetList/index.tsx +++ b/src/components/@shared/AssetList/index.tsx @@ -9,6 +9,7 @@ import AssetType from '../AssetType' import { getServiceByName } from '@utils/ddo' import AssetViewSelector, { AssetViewOptions } from './AssetViewSelector' import Time from '../atoms/Time' +import Loader from '../atoms/Loader' const columns: TableOceanColumn[] = [ { @@ -84,6 +85,7 @@ export default function AssetList({ showPagination, page, totalPages, + isLoading, onPageChange, className, noPublisher, @@ -103,7 +105,9 @@ export default function AssetList({ const styleClasses = `${styles.assetList} ${className || ''}` - return ( + return isLoading ? ( + + ) : ( <> {showAssetViewSelector && ( No results found )} - {showPagination && ( -
-
-

{teaser.title}

- -
-
- {paragraphs.map((paragraph, i) => ( -
-
- -
-
-

{paragraph.title}

- - -
+
+
+

{teaser.title}

+ +
+
+ {paragraphs.map((paragraph, i) => ( +
+
+ +
+
+

{paragraph.title}

+ +
- ))} -
+
+ ))}
- +
) } diff --git a/src/components/Privacy/PrivacyPreferenceCenter.module.css b/src/components/Privacy/PrivacyPreferenceCenter.module.css index 21d0a52a7..707f713d4 100644 --- a/src/components/Privacy/PrivacyPreferenceCenter.module.css +++ b/src/components/Privacy/PrivacyPreferenceCenter.module.css @@ -105,6 +105,7 @@ left: -25rem; opacity: 0; box-shadow: none; + pointer-events: none; } /* Adjust for larger screen sizes */ diff --git a/src/components/Profile/History/PublishedList.module.css b/src/components/Profile/History/PublishedList.module.css index 4ef16e734..98c16c773 100644 --- a/src/components/Profile/History/PublishedList.module.css +++ b/src/components/Profile/History/PublishedList.module.css @@ -1,7 +1,11 @@ -.filters { - margin-top: -1rem; +.container { + composes: container from '../../../components/Search/index.module.css'; } -.assets { - margin-top: calc(var(--spacer) / 3); +.filterContainer { + composes: filterContainer from '../../../components/Search/index.module.css'; +} + +.results { + composes: results from '../../../components/Search/index.module.css'; } diff --git a/src/components/Profile/History/PublishedList.tsx b/src/components/Profile/History/PublishedList.tsx index ceabb2dd9..05714e5bd 100644 --- a/src/components/Profile/History/PublishedList.tsx +++ b/src/components/Profile/History/PublishedList.tsx @@ -5,10 +5,12 @@ import { getPublishedAssets } from '@utils/aquarius' import { useUserPreferences } from '@context/UserPreferences' import styles from './PublishedList.module.css' import { useCancelToken } from '@hooks/useCancelToken' -import Filters from '../../Search/Filters' +import Filter from '@components/Search/Filter' import { useMarketMetadata } from '@context/MarketMetadata' import { CancelToken } from 'axios' import { useProfile } from '@context/Profile' +import { useFilter, Filters } from '@context/Filter' +import useDebounce from '@hooks/useDebounce' export default function PublishedList({ accountId @@ -18,12 +20,10 @@ export default function PublishedList({ const { appConfig } = useMarketMetadata() const { chainIds } = useUserPreferences() const { ownAccount } = useProfile() + const { filters, ignorePurgatory } = useFilter() const [queryResult, setQueryResult] = useState() - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) const [page, setPage] = useState(1) - const [service, setServiceType] = useState() - const [access, setAccessType] = useState() - const [ignorePurgatory, setIgnorePurgatory] = useState(true) const newCancelToken = useCancelToken() const getPublished = useCallback( @@ -31,8 +31,7 @@ export default function PublishedList({ accountId: string, chainIds: number[], page: number, - service: string, - access: string, + filters: Filters, ignorePurgatory: boolean, cancelToken: CancelToken ) => { @@ -44,9 +43,8 @@ export default function PublishedList({ cancelToken, ownAccount && ignorePurgatory, ownAccount, - page, - service, - access + filters, + page ) setQueryResult(result) } catch (error) { @@ -62,55 +60,52 @@ export default function PublishedList({ if (queryResult && queryResult.totalPages < page) setPage(1) }, [page, queryResult]) - useEffect(() => { - if (!accountId) return + useDebounce( + () => { + if (!accountId) return - getPublished( + getPublished( + accountId, + chainIds, + page, + filters, + ignorePurgatory, + newCancelToken() + ) + }, + [ accountId, - chainIds, page, - service, - access, - ignorePurgatory, - newCancelToken() - ) - }, [ - accountId, - page, - appConfig?.metadataCacheUri, - chainIds, - newCancelToken, - getPublished, - service, - access, - ignorePurgatory - ]) + appConfig?.metadataCacheUri, + chainIds, + newCancelToken, + getPublished, + filters, + ignorePurgatory + ], + 500 + ) return accountId ? ( - <> - - { - setPage(newPage) - }} - className={styles.assets} - noPublisher - showAssetViewSelector - /> - +
+
+ +
+
+ { + setPage(newPage) + }} + noPublisher + showAssetViewSelector + /> +
+
) : (
Please connect your wallet.
) diff --git a/src/components/Search/Filter.module.css b/src/components/Search/Filter.module.css new file mode 100644 index 000000000..25d67d5bb --- /dev/null +++ b/src/components/Search/Filter.module.css @@ -0,0 +1,54 @@ +.sidePositioning { + display: none; +} + +.topPositioning { + display: flex; + flex-wrap: wrap; + gap: calc(var(--spacer) / 4); +} + +.filterList { + display: flex; + flex-direction: column; + gap: calc(var(--spacer) / 2); +} + +.filtersHeader { + display: flex; + justify-content: space-between; +} + +.filterType *, +.compactOptionsContainer > * { + margin: 0; +} + +.filterTypeLabel { + margin: calc(var(--spacer) / 4) 0; +} + +.filterType > div:not(:last-child) { + margin-bottom: calc(var(--spacer) / 8); +} + +.compactFilterContainer { + min-width: max-content; + height: min-content; + padding: calc(var(--spacer) / 4); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.compactOptionsContainer > div { + margin-top: calc(var(--spacer) / 4) 0; +} + +@media screen and (min-width: 65rem) { + .sidePositioning { + display: block; + } + .topPositioning { + display: none; + } +} diff --git a/src/components/Search/Filter.tsx b/src/components/Search/Filter.tsx new file mode 100644 index 000000000..ab1eb7cd3 --- /dev/null +++ b/src/components/Search/Filter.tsx @@ -0,0 +1,260 @@ +import React, { ReactElement, useEffect } from 'react' +import classNames from 'classnames/bind' +import { addExistingParamsToUrl } from './utils' +import Button from '@shared/atoms/Button' +import { + FilterByAccessOptions, + FilterByTypeOptions +} from '../../@types/aquarius/SearchQuery' +import { useRouter } from 'next/router' +import queryString from 'query-string' +import styles from './Filter.module.css' +import { useFilter, Filters } from '@context/Filter' +import Input from '@components/@shared/FormInput' +import Accordion from '@components/@shared/Accordion' +import customFilters from '../../../filters.config' + +const cx = classNames.bind(styles) + +interface FilterStructure { + id: string + label: string + type: string + options: { + label: string + value: string + }[] +} + +const filterList: FilterStructure[] = [ + { + id: 'serviceType', + label: 'Service Type', + type: 'filterList', + options: [ + { label: 'datasets', value: FilterByTypeOptions.Data }, + { label: 'algorithms', value: FilterByTypeOptions.Algorithm } + ] + }, + { + id: 'accessType', + label: 'Access Type', + type: 'filterList', + options: [ + { label: 'download', value: FilterByAccessOptions.Download }, + { label: 'compute', value: FilterByAccessOptions.Compute } + ] + }, + ...(Array.isArray(customFilters?.filters) && + customFilters?.filters?.length > 0 && + customFilters?.filters.some((filter) => filter !== undefined) + ? // eslint-disable-next-line no-unsafe-optional-chaining + customFilters?.filters + : []) +] + +export const filterSets = customFilters?.filterSets || {} + +const purgatoryFilterItem = { display: 'show purgatory ', value: 'purgatory' } + +export function getInitialFilters( + parsedUrlParams: queryString.ParsedQuery, + filterIds: string[] +): Filters { + if (!parsedUrlParams || !filterIds) return + + const initialFilters = {} + filterIds.forEach((id) => + !parsedUrlParams?.[id] + ? (initialFilters[id] = []) + : Array.isArray(parsedUrlParams?.[id]) + ? (initialFilters[id] = parsedUrlParams?.[id]) + : (initialFilters[id] = [parsedUrlParams?.[id]]) + ) + + return initialFilters as Filters +} + +export default function Filter({ + addFiltersToUrl, + showPurgatoryOption, + expanded, + className +}: { + addFiltersToUrl?: boolean + showPurgatoryOption?: boolean + expanded?: boolean + className?: string +}): ReactElement { + const { filters, setFilters, ignorePurgatory, setIgnorePurgatory } = + useFilter() + + const router = useRouter() + + const parsedUrl = queryString.parse(location.search, { + arrayFormat: 'separator' + }) + + useEffect(() => { + const initialFilters = getInitialFilters(parsedUrl, Object.keys(filters)) + setFilters(initialFilters) + }, []) + + async function applyFilter(filter: string[], filterId: string) { + if (!addFiltersToUrl) return + + let urlLocation = await addExistingParamsToUrl(location, [filterId]) + + if (filter.length > 0 && urlLocation.indexOf(filterId) === -1) { + const parsedFilter = filter.join(',') + urlLocation = `${urlLocation}&${filterId}=${parsedFilter}` + } + + router.push(urlLocation) + } + + async function handleSelectedFilter(value: string, filterId: string) { + const updatedFilters = filters[filterId].includes(value) + ? { ...filters, [filterId]: filters[filterId].filter((e) => e !== value) } + : { ...filters, [filterId]: [...filters[filterId], value] } + setFilters(updatedFilters) + + await applyFilter(updatedFilters[filterId], filterId) + } + + async function clearFilters(addFiltersToUrl: boolean) { + const clearedFilters = { ...filters } + Object.keys(clearedFilters).forEach((key) => (clearedFilters[key] = [])) + setFilters(clearedFilters) + + if (ignorePurgatory !== undefined && setIgnorePurgatory !== undefined) + setIgnorePurgatory(true) + + if (!addFiltersToUrl) return + const urlLocation = await addExistingParamsToUrl( + location, + Object.keys(clearedFilters) + ) + router.push(urlLocation) + } + + const styleClasses = cx({ + filterList: true, + [className]: className + }) + + const selectedFiltersCount = Object.values(filters).reduce( + (acc, filter) => acc + filter.length, + showPurgatoryOption && ignorePurgatory ? 1 : 0 + ) + + return ( + <> +
+ 0 && ( + + ) + } + > +
+ {filterList.map((filter) => ( +
+
{filter.label}
+ {filter.options.map((option) => { + const isSelected = filters[filter.id].includes(option.value) + return ( + { + handleSelectedFilter(option.value, filter.id) + }} + /> + ) + })} +
+ ))} + {showPurgatoryOption && ( +
+
Purgatory
+ { + setIgnorePurgatory(!ignorePurgatory) + }} + /> +
+ )} +
+
+
+
+ {filterList.map((filter) => ( +
+ +
+ {filter.options.map((option) => { + const isSelected = filters[filter.id].includes(option.value) + return ( + { + handleSelectedFilter(option.value, filter.id) + }} + /> + ) + })} +
+
+
+ ))} + {showPurgatoryOption && ( +
+ + { + setIgnorePurgatory(!ignorePurgatory) + }} + /> + +
+ )} +
+ + ) +} diff --git a/src/components/Search/Filters.module.css b/src/components/Search/Filters.module.css deleted file mode 100644 index 03ef9c5f0..000000000 --- a/src/components/Search/Filters.module.css +++ /dev/null @@ -1,70 +0,0 @@ -.filterList, -div.filterList { - white-space: normal; - margin-top: 0; - margin-bottom: 0; - gap: calc(var(--spacer) / 4) calc(var(--spacer) / 2); -} - -.filter, -.filterList > div { - display: inline-block; -} - -.filter, -button.filter, -.filter:hover, -.filter:active, -.filter:focus { - border: var(--filter-border-size) solid var(--filter-border-color); - border-radius: var(--filter-border-radius); - margin-right: calc(var(--spacer) / 6); - margin-bottom: calc(var(--spacer) / 6); - color: var(--filter-font-color); - background: var(--filter-background); - - /* the only thing not possible to overwrite button style="text" with more specifity of selectors, so sledgehammer */ - padding: calc(var(--spacer) / 6) !important; -} - -.filter:hover, -.filter:focus { - color: var(--filter-hover-font-color); - background: inherit; - transform: none; -} - -.filter.selected { - color: var(--filter-selected-font-color); - background: var(--filter-selected-background); - border-color: var(--filter-selected-border-color); -} - -.filter.selected::after { - content: '✕'; - margin-left: calc(var(--spacer) / 6); - color: var(--filter-selected-font-color); -} - -.showClear:hover { - display: inline-flex; - color: var(--filter-clear-hover-font-color); -} -.showClear { - display: inline-flex; - text-transform: capitalize; - color: var(--filter-clear-font-color); - font-weight: var(--font-weight-base); - margin-left: calc(var(--spacer) / 6); -} - -.hideClear { - display: none !important; -} - -@media screen and (min-width: 35rem) { - .filterList, - div.filterList { - flex-direction: row; - } -} diff --git a/src/components/Search/Filters.tsx b/src/components/Search/Filters.tsx deleted file mode 100644 index 8bb85c4f3..000000000 --- a/src/components/Search/Filters.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React, { ReactElement, useState } from 'react' -import classNames from 'classnames/bind' -import { addExistingParamsToUrl } from './utils' -import Button from '@shared/atoms/Button' -import { - FilterByAccessOptions, - FilterByTypeOptions -} from '../../@types/aquarius/SearchQuery' -import { useRouter } from 'next/router' -import styles from './Filters.module.css' - -const cx = classNames.bind(styles) - -const clearFilters = [{ display: 'Clear', value: '' }] - -const serviceFilterItems = [ - { display: 'datasets', value: FilterByTypeOptions.Data }, - { display: 'algorithms', value: FilterByTypeOptions.Algorithm } -] - -const accessFilterItems = [ - { display: 'download', value: FilterByAccessOptions.Download }, - { display: 'compute', value: FilterByAccessOptions.Compute } -] - -const purgatoryFilterItem = { display: 'purgatory ', value: 'purgatory' } - -export default function FilterPrice({ - serviceType, - accessType, - setServiceType, - setAccessType, - addFiltersToUrl, - ignorePurgatory, - setIgnorePurgatory, - className -}: { - serviceType: string - accessType: string - setServiceType: React.Dispatch> - setAccessType: React.Dispatch> - addFiltersToUrl?: boolean - ignorePurgatory?: boolean - setIgnorePurgatory?: (value: boolean) => void - className?: string -}): ReactElement { - const router = useRouter() - const [serviceSelections, setServiceSelections] = useState([]) - const [accessSelections, setAccessSelections] = useState([]) - - async function applyFilter(filter: string, filterType: string) { - filterType === 'accessType' ? setAccessType(filter) : setServiceType(filter) - if (addFiltersToUrl) { - let urlLocation = '' - if (filterType.localeCompare('accessType') === 0) { - urlLocation = await addExistingParamsToUrl(location, ['accessType']) - } else { - urlLocation = await addExistingParamsToUrl(location, ['serviceType']) - } - - if (filter && location.search.indexOf(filterType) === -1) { - filterType === 'accessType' - ? (urlLocation = `${urlLocation}&accessType=${filter}`) - : (urlLocation = `${urlLocation}&serviceType=${filter}`) - } - - router.push(urlLocation) - } - } - - async function handleSelectedFilter(isSelected: boolean, value: string) { - if ( - value === FilterByAccessOptions.Download || - value === FilterByAccessOptions.Compute - ) { - if (isSelected) { - if (accessSelections.length > 1) { - // both selected -> select the other one - const otherValue = accessFilterItems.find( - (p) => p.value !== value - ).value - await applyFilter(otherValue, 'accessType') - setAccessSelections([otherValue]) - } else { - // only the current one selected -> deselect it - await applyFilter(undefined, 'accessType') - setAccessSelections([]) - } - } else { - if (accessSelections.length) { - // one already selected -> both selected - await applyFilter(undefined, 'accessType') - setAccessSelections(accessFilterItems.map((p) => p.value)) - } else { - // none selected -> select - await applyFilter(value, 'accessType') - setAccessSelections([value]) - } - } - } else { - if (isSelected) { - if (serviceSelections.length > 1) { - const otherValue = serviceFilterItems.find( - (p) => p.value !== value - ).value - await applyFilter(otherValue, 'serviceType') - setServiceSelections([otherValue]) - } else { - await applyFilter(undefined, 'serviceType') - setServiceSelections([]) - } - } else { - if (serviceSelections.length) { - await applyFilter(undefined, 'serviceType') - setServiceSelections(serviceFilterItems.map((p) => p.value)) - } else { - await applyFilter(value, 'serviceType') - setServiceSelections([value]) - } - } - } - } - - async function applyClearFilter(addFiltersToUrl: boolean) { - setServiceSelections([]) - setAccessSelections([]) - setServiceType(undefined) - setAccessType(undefined) - if (ignorePurgatory !== undefined && setIgnorePurgatory !== undefined) - setIgnorePurgatory(true) - - if (addFiltersToUrl) { - let urlLocation = await addExistingParamsToUrl(location, [ - 'accessType', - 'serviceType' - ]) - urlLocation = `${urlLocation}` - router.push(urlLocation) - } - } - - const styleClasses = cx({ - filterList: true, - [className]: className - }) - - return ( -
-
- {serviceFilterItems.map((e, index) => { - const isServiceSelected = - e.value === serviceType || serviceSelections.includes(e.value) - const selectFilter = cx({ - [styles.selected]: isServiceSelected, - [styles.filter]: true - }) - return ( - - ) - })} -
-
- {accessFilterItems.map((e, index) => { - const isAccessSelected = - e.value === accessType || accessSelections.includes(e.value) - const selectFilter = cx({ - [styles.selected]: isAccessSelected, - [styles.filter]: true - }) - return ( - - ) - })} -
-
- {ignorePurgatory !== undefined && setIgnorePurgatory !== undefined && ( - - )} - {clearFilters.map((e, index) => { - const showClear = - accessSelections.length > 0 || serviceSelections.length > 0 - return ( - - ) - })} -
-
- ) -} diff --git a/src/components/Search/index.module.css b/src/components/Search/index.module.css index 0d8ccfa23..19d1f7b04 100644 --- a/src/components/Search/index.module.css +++ b/src/components/Search/index.module.css @@ -1,17 +1,33 @@ -.row { - display: inline-flex; +.container { + display: flex; flex-direction: column; - justify-content: space-between; - width: 100%; - margin-bottom: calc(var(--spacer) / 2); + gap: calc(var(--spacer) / 2); } -.row > div { - margin-bottom: calc(var(--spacer) / 2); +.filterContainer { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: calc(var(--spacer) / 4); + width: 100%; + padding-right: calc(var(--spacer) / 1.2); } -@media (min-width: 55rem) { - .row { +@media screen and (min-width: 65rem) { + .container { flex-direction: row; + gap: calc(var(--spacer) / 1.2); + } + .filterContainer { + flex-direction: column; + width: 15rem; + border-right: 1px solid var(--border-color); + } + .filterContainer > div { + padding-bottom: calc(var(--spacer) / 1.2); + border-bottom: 1px solid var(--border-color); + } + .results { + width: 100%; } } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 717cf65eb..71561be08 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,13 +1,14 @@ import React, { ReactElement, useState, useEffect, useCallback } from 'react' import AssetList from '@shared/AssetList' import queryString from 'query-string' -import Filters from './Filters' +import Filter from './Filter' import Sort from './sort' import { getResults, updateQueryStringParameter } from './utils' import { useUserPreferences } from '@context/UserPreferences' import { useCancelToken } from '@hooks/useCancelToken' import styles from './index.module.css' import { useRouter } from 'next/router' +import useDebounce from '@hooks/useDebounce' export default function SearchPage({ setTotalResults, @@ -20,21 +21,14 @@ export default function SearchPage({ const [parsed, setParsed] = useState>() const { chainIds } = useUserPreferences() const [queryResult, setQueryResult] = useState() - const [loading, setLoading] = useState() - const [serviceType, setServiceType] = useState() - const [accessType, setAccessType] = useState() - const [sortType, setSortType] = useState() - const [sortDirection, setSortDirection] = useState() + const [loading, setLoading] = useState(true) const newCancelToken = useCancelToken() useEffect(() => { - const parsed = queryString.parse(location.search) - const { sort, sortOrder, serviceType, accessType } = parsed + const parsed = queryString.parse(location.search, { + arrayFormat: 'separator' + }) setParsed(parsed) - setServiceType(serviceType as string) - setAccessType(accessType as string) - setSortDirection(sortOrder as string) - setSortType(sort as string) }, [router]) const updatePage = useCallback( @@ -74,29 +68,20 @@ export default function SearchPage({ if (queryResult.totalPages < Number(page)) updatePage(1) }, [parsed, queryResult, updatePage]) - useEffect(() => { - if (!parsed || !chainIds) return - fetchAssets(parsed, chainIds) - }, [parsed, chainIds, newCancelToken, fetchAssets]) + useDebounce( + () => { + if (!parsed || !chainIds) return + fetchAssets(parsed, chainIds) + }, + [parsed, chainIds, newCancelToken, fetchAssets], + 500 + ) return ( - <> -
-
- - -
+
+
+ +
- +
) } diff --git a/src/components/Search/sort.module.css b/src/components/Search/sort.module.css index d5765c9dc..cf1671942 100644 --- a/src/components/Search/sort.module.css +++ b/src/components/Search/sort.module.css @@ -1,52 +1,34 @@ +.sidePositioning { + composes: sidePositioning from './Filter.module.css'; +} + +.topPositioning { + composes: topPositioning from './Filter.module.css'; +} + .sortList { - padding: 0 calc(var(--spacer) / 10); display: flex; - align-items: center; - border-radius: var(--filter-border-radius); - border: var(--filter-border-size) solid var(--filter-border-color); - background: var(--filter-background); - overflow-y: auto; + flex-direction: column; + gap: calc(var(--spacer) / 2); +} + +.sortList div *, +.compactOptionsContainer > * { + margin: 0; } -.sortLabel { - composes: label from '@shared/FormInput/Label.module.css'; - padding: 0; - margin-bottom: 0; - margin-left: calc(var(--spacer) / 2); - margin-right: calc(var(--spacer) / 1.5); - text-transform: uppercase; - color: var(--filter-font-color); - font-size: var(--font-size-small); +.sortList div h5 { + margin: calc(var(--spacer) / 4) 0; } -.sorted { - display: inline-block; - padding: calc(var(--spacer) / 6) calc(var(--spacer) / 2); - margin-right: calc(var(--spacer) / 4); - color: var(--filter-font-color); - text-transform: capitalize; - border-radius: 0; - font-weight: var(--font-weight-base); - background: var(--filter-background); - box-shadow: none; - white-space: nowrap; +.sortList > div > div:not(:last-child) { + margin-bottom: calc(var(--spacer) / 8); } -.sorted:hover, -.sorted:focus, -.sorted.selected { - color: var(--filter-hover-font-color); - background: inherit; - transform: none; - box-shadow: none; +.compactFilterContainer { + composes: compactFilterContainer from './Filter.module.css'; } -.direction { - background: transparent; - border: none; - color: inherit; - font-size: 0.75em; - outline: none; - margin-left: calc(var(--spacer) / 8); - margin-top: calc(var(--spacer) / 14); +.compactOptionsContainer > div { + margin-top: calc(var(--spacer) / 4) 0; } diff --git a/src/components/Search/sort.tsx b/src/components/Search/sort.tsx index 14cd54300..c64f3626d 100644 --- a/src/components/Search/sort.tsx +++ b/src/components/Search/sort.tsx @@ -1,15 +1,15 @@ -import React, { ReactElement } from 'react' +import React, { ReactElement, useEffect } from 'react' import { addExistingParamsToUrl } from './utils' -import Button from '@shared/atoms/Button' import styles from './sort.module.css' -import classNames from 'classnames/bind' import { SortDirectionOptions, SortTermOptions } from '../../@types/aquarius/SearchQuery' import { useRouter } from 'next/router' - -const cx = classNames.bind(styles) +import Accordion from '@components/@shared/Accordion' +import Input from '@components/@shared/FormInput' +import { Sort as SortInterface, useFilter } from '@context/Filter' +import queryString from 'query-string' const sortItems = [ { display: 'Relevance', value: SortTermOptions.Relevance }, @@ -18,69 +18,133 @@ const sortItems = [ { display: 'Price', value: SortTermOptions.Price } ] +const sortDirections = [ + { display: '\u2191 Ascending', value: SortDirectionOptions.Ascending }, + { display: '\u2193 Descending', value: SortDirectionOptions.Descending } +] + +function getInitialFilters( + parsedUrlParams: queryString.ParsedQuery, + filterIds: (keyof SortInterface)[] +): SortInterface { + if (!parsedUrlParams || !filterIds) return + + const initialFilters = {} + filterIds.forEach((id) => (initialFilters[id] = parsedUrlParams?.[id])) + + return initialFilters as SortInterface +} + export default function Sort({ - sortType, - setSortType, - sortDirection, - setSortDirection + expanded }: { - sortType: string - setSortType: React.Dispatch> - sortDirection: string - setSortDirection: React.Dispatch> + expanded?: boolean }): ReactElement { + const { sort, setSort } = useFilter() + const router = useRouter() - const directionArrow = String.fromCharCode( - sortDirection === SortDirectionOptions.Ascending ? 9650 : 9660 - ) - async function sortResults(sortBy?: string, direction?: string) { + + const parsedUrl = queryString.parse(location.search, { + arrayFormat: 'separator' + }) + + useEffect(() => { + const initialFilters = getInitialFilters( + parsedUrl, + Object.keys(sort) as (keyof SortInterface)[] + ) + setSort(initialFilters) + }, []) + + async function sortResults( + sortBy?: SortTermOptions, + direction?: SortDirectionOptions + ) { let urlLocation: string if (sortBy) { urlLocation = await addExistingParamsToUrl(location, ['sort']) urlLocation = `${urlLocation}&sort=${sortBy}` - setSortType(sortBy) + setSort({ ...sort, sort: sortBy }) } else if (direction) { urlLocation = await addExistingParamsToUrl(location, ['sortOrder']) urlLocation = `${urlLocation}&sortOrder=${direction}` - setSortDirection(direction) + setSort({ ...sort, sortOrder: direction }) } router.push(urlLocation) } - function handleSortButtonClick(value: string) { - if (value === sortType) { - if (sortDirection === SortDirectionOptions.Descending) { - sortResults(null, SortDirectionOptions.Ascending) - } else { - sortResults(null, SortDirectionOptions.Descending) - } - } else { - sortResults(value, null) - } - } + return ( -
- - {sortItems.map((e, index) => { - const sorted = cx({ - [styles.selected]: e.value === sortType, - [styles.sorted]: true - }) - return ( - - ) - })} -
+ <> +
+ +
+
+
Type
+ {sortItems.map((item) => ( + sortResults(item.value, null)} + /> + ))} +
+
+
Direction
+ {sortDirections.map((item) => ( + sortResults(null, item.value)} + /> + ))} +
+
+
+
+
+
+ +
+ {sortItems.map((item) => ( + sortResults(item.value, null)} + /> + ))} +
+
+
+
+ +
+ {sortDirections.map((item) => ( + sortResults(null, item.value)} + /> + ))} +
+
+
+
+ ) } diff --git a/src/components/Search/utils.ts b/src/components/Search/utils.ts index 289d790df..9c6886859 100644 --- a/src/components/Search/utils.ts +++ b/src/components/Search/utils.ts @@ -3,14 +3,18 @@ import { escapeEsReservedCharacters, generateBaseQuery, getFilterTerm, + parseFilters, queryMetadata } from '@utils/aquarius' import queryString from 'query-string' import { CancelToken } from 'axios' import { + FilterByAccessOptions, + FilterByTypeOptions, SortDirectionOptions, SortTermOptions } from '../../@types/aquarius/SearchQuery' +import { filterSets, getInitialFilters } from './Filter' export function updateQueryStringParameter( uri: string, @@ -36,8 +40,9 @@ export function getSearchQuery( offset?: string, sort?: string, sortDirection?: string, - serviceType?: string, - accessType?: string + serviceType?: string | string[], + accessType?: string | string[], + filterSet?: string | string[] ): SearchQuery { text = escapeEsReservedCharacters(text) const emptySearchTerm = text === undefined || text === '' @@ -112,10 +117,12 @@ export function getSearchQuery( ] } } - accessType !== undefined && - filters.push(getFilterTerm('services.type', accessType)) - serviceType !== undefined && - filters.push(getFilterTerm('metadata.type', serviceType)) + + const filtersList = getInitialFilters( + { accessType, serviceType, filterSet }, + ['accessType', 'serviceType', 'filterSet'] + ) + parseFilters(filtersList, filterSets).forEach((term) => filters.push(term)) const baseQueryParams = { chainIds, @@ -142,8 +149,9 @@ export async function getResults( offset?: string sort?: string sortOrder?: string - serviceType?: string - accessType?: string + serviceType?: string[] + accessType?: string[] + filterSet?: string[] }, chainIds: number[], cancelToken?: CancelToken @@ -157,7 +165,8 @@ export async function getResults( sort, sortOrder, serviceType, - accessType + accessType, + filterSet } = params const searchQuery = getSearchQuery( @@ -170,7 +179,8 @@ export async function getResults( sort, sortOrder, serviceType, - accessType + accessType, + filterSet ) const queryResult = await queryMetadata(searchQuery, cancelToken) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5e421c450..53da84b4b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -12,6 +12,7 @@ import MarketMetadataProvider from '@context/MarketMetadata' import { WagmiConfig } from 'wagmi' import { ConnectKitProvider } from 'connectkit' import { connectKitTheme, wagmiClient } from '@utils/wallet' +import { FilterProvider } from '@context/Filter' function MyApp({ Component, pageProps }: AppProps): ReactElement { Decimal.set({ rounding: 1 }) @@ -28,9 +29,11 @@ function MyApp({ Component, pageProps }: AppProps): ReactElement { - - - + + + + +