Skip to content

Commit

Permalink
feat: functional implementation of pool filter
Browse files Browse the repository at this point in the history
  • Loading branch information
Hornebom committed Jul 28, 2023
1 parent 1cdd0e0 commit 3fa2791
Show file tree
Hide file tree
Showing 11 changed files with 396 additions and 53 deletions.
10 changes: 10 additions & 0 deletions centrifuge-app/src/components/GlobalStyle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ export const GlobalStyle = createGlobalStyle`
ul {
list-style: none;
}
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
`
98 changes: 98 additions & 0 deletions centrifuge-app/src/components/PoolFilter/FilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Box, IconFilter, Text } from '@centrifuge/fabric'
import * as React from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { SearchKeys } from './types'
import { toKebabCase } from './utils'

export type FilterMenuProps = {
label: string
options: string[]
searchKey: SearchKeys['ASSET_CLASS' | 'POOL_STATUS']
}

export function FilterMenu({ label, options, searchKey }: FilterMenuProps) {
const history = useHistory()
const { pathname, search } = useLocation()
const [isOpen, setIsOpen] = React.useState(false)
const id = React.useId()

const form = React.useRef<HTMLFormElement>(null)

const restSearchParams = React.useMemo(() => {
const searchParams = new URLSearchParams(search)
searchParams.delete(searchKey)
return searchParams
}, [search])

Check warning on line 25 in centrifuge-app/src/components/PoolFilter/FilterMenu.tsx

View workflow job for this annotation

GitHub Actions / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

Check warning on line 25 in centrifuge-app/src/components/PoolFilter/FilterMenu.tsx

View workflow job for this annotation

GitHub Actions / app-pr-deploy / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

const selectedOptions = React.useMemo(() => {
const searchParams = new URLSearchParams(search)
return searchParams.getAll(searchKey)
}, [search])

Check warning on line 30 in centrifuge-app/src/components/PoolFilter/FilterMenu.tsx

View workflow job for this annotation

GitHub Actions / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

Check warning on line 30 in centrifuge-app/src/components/PoolFilter/FilterMenu.tsx

View workflow job for this annotation

GitHub Actions / app-pr-deploy / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

function handleChange() {
const formData = new FormData(form.current ?? undefined)
const entries = formData.getAll(searchKey) as string[]
const searchParams = new URLSearchParams(entries.map((entry) => [searchKey, entry]))

history.push({
pathname,
search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`,
})
}

function deselectAll() {
history.push({
pathname,
search: restSearchParams.size > 0 ? `?${restSearchParams}` : '',
})
}

function selectAll() {
const searchParams = new URLSearchParams(options.map((option) => [searchKey, toKebabCase(option)]))

history.push({
pathname,
search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`,
})
}

return (
<Box position="relative">
<Text as="button" id={`${id}-button`} aria-controls={`${id}-menu`} onClick={() => setIsOpen(!isOpen)}>
{label}
<IconFilter color={selectedOptions.length ? 'green' : 'black'} />
</Text>

{isOpen && (
<Box as="form" ref={form} hidden={!isOpen} aria-labelledby={`${id}-button`} aria-expanded={!!isOpen}>
<Box as="fieldset">
<Box as="legend" className="visually-hidden">
Filter {label} by:
</Box>
{options.map((option, index) => {
const value = toKebabCase(option)
const checked = selectedOptions.includes(value)

return (
<Box as="label" key={`${value}${index}`} display="block">
<input type="checkbox" name={searchKey} value={value} onChange={handleChange} checked={checked} />
{option}
</Box>
)
})}
</Box>

{selectedOptions.length === options.length ? (
<button type="button" onClick={() => deselectAll()}>
Deselect all
</button>
) : (
<button type="button" onClick={() => selectAll()}>
Select all
</button>
)}
</Box>
)}
</Box>
)
}
64 changes: 64 additions & 0 deletions centrifuge-app/src/components/PoolFilter/SortButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { IconChevronDown, IconChevronUp, Stack, Text } from '@centrifuge/fabric'
import * as React from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { SEARCH_KEYS } from './config'
import { SortBy } from './types'

export type SortButtonProps = {
label: string
searchKey: SortBy
}

export function SortButton({ label, searchKey }: SortButtonProps) {
const history = useHistory()
const { pathname, search } = useLocation()

const sorting = React.useMemo(() => {
const searchParams = new URLSearchParams(search)

return {
isActive: searchParams.get(SEARCH_KEYS.SORT_BY) === searchKey,
direction: searchParams.get(SEARCH_KEYS.SORT),
}
}, [search])

Check warning on line 23 in centrifuge-app/src/components/PoolFilter/SortButton.tsx

View workflow job for this annotation

GitHub Actions / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

Check warning on line 23 in centrifuge-app/src/components/PoolFilter/SortButton.tsx

View workflow job for this annotation

GitHub Actions / app-pr-deploy / build-app

React Hook React.useMemo has a missing dependency: 'searchKey'. Either include it or remove the dependency array

function handleClick() {
const restSearchParams = new URLSearchParams(search)
restSearchParams.delete(SEARCH_KEYS.SORT_BY)
restSearchParams.delete(SEARCH_KEYS.SORT)

const searchParams = new URLSearchParams({
[SEARCH_KEYS.SORT_BY]: searchKey,
[SEARCH_KEYS.SORT]: sorting.direction === 'asc' ? 'desc' : 'asc',
})

history.push({
pathname,
search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`,
})
}

return (
<Text
as="button"
variant="body3"
onClick={handleClick}
color={sorting.isActive ? 'green' : 'blue'}
aria-label={
!sorting.isActive
? `Sort ${label}`
: sorting.direction === 'asc'
? `Sort ${label} descending`
: `Sort ${label} ascending`
}
aria-live

Check warning on line 54 in centrifuge-app/src/components/PoolFilter/SortButton.tsx

View workflow job for this annotation

GitHub Actions / build-app

The value for aria-live must be a single token from the following: assertive,off,polite

Check warning on line 54 in centrifuge-app/src/components/PoolFilter/SortButton.tsx

View workflow job for this annotation

GitHub Actions / app-pr-deploy / build-app

The value for aria-live must be a single token from the following: assertive,off,polite
>
{label}

<Stack as="span" width={14}>
<IconChevronUp size={14} color={sorting.isActive && sorting.direction === 'asc' ? 'green' : 'gray'} />
<IconChevronDown size={14} color={sorting.isActive && sorting.direction === 'desc' ? 'green' : 'gray'} />
</Stack>
</Text>
)
}
33 changes: 33 additions & 0 deletions centrifuge-app/src/components/PoolFilter/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { config } from '../../config'
import { FilterMenuProps } from './FilterMenu'
import { SortButtonProps } from './SortButton'

export const SEARCH_KEYS = {
SORT_BY: 'sort-by',
SORT: 'sort',
ASSET_CLASS: 'asset-class',
POOL_STATUS: 'pool-status',
VALUE_LOCKED: 'value-locked',
APR: 'apr',
} as const

export const poolFilterConfig = {
assetClass: {
label: 'Asset class',
options: [...config.assetClasses, 'Credit'], // todo: is 'Credit' tinlke specific or is the config outdated?
searchKey: SEARCH_KEYS.ASSET_CLASS,
} as FilterMenuProps,
poolStatus: {
label: 'Pool status',
options: ['Open for investments', 'Maker pool', 'Closed'],
searchKey: SEARCH_KEYS.POOL_STATUS,
} as FilterMenuProps,
valueLocked: {
label: 'Value locked',
searchKey: SEARCH_KEYS.VALUE_LOCKED,
} as SortButtonProps,
apr: {
label: 'APR',
searchKey: SEARCH_KEYS.APR,
} as SortButtonProps,
}
21 changes: 21 additions & 0 deletions centrifuge-app/src/components/PoolFilter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Grid, Text } from '@centrifuge/fabric'
import * as React from 'react'
import { poolFilterConfig } from './config'
import { FilterMenu } from './FilterMenu'
import { SortButton } from './SortButton'

export function PoolFilter() {
return (
<Grid gridTemplateColumns="repeat(5, minmax(0, 1fr))" alignItems="start">
<Text>Pool name</Text>

<FilterMenu {...poolFilterConfig.assetClass} />

<SortButton {...poolFilterConfig.valueLocked} />

<SortButton {...poolFilterConfig.apr} />

<FilterMenu {...poolFilterConfig.poolStatus} />
</Grid>
)
}
6 changes: 6 additions & 0 deletions centrifuge-app/src/components/PoolFilter/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SEARCH_KEYS } from './config'

export type SortDirection = 'asc' | 'desc'
export type SortBy = 'value-locked' | 'apr'
export type FilterBy = 'asset-class' | 'pool-status'
export type SearchKeys = typeof SEARCH_KEYS
53 changes: 53 additions & 0 deletions centrifuge-app/src/components/PoolFilter/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { PoolCardProps } from '../../pages/Pools'
import { poolFilterConfig, SEARCH_KEYS } from './config'
import { SortBy, SortDirection } from './types'

export function toKebabCase(string: string) {
return string.toLowerCase().split(' ').join('-')
}

export function filterPools(pools: PoolCardProps[], searchParams: URLSearchParams) {
let filtered = pools
const assetClasses = searchParams.getAll(poolFilterConfig.assetClass.searchKey)
const poolStatuses = searchParams.getAll(poolFilterConfig.poolStatus.searchKey)
const sortDirection = searchParams.get('sort') as SortDirection
const sortBy = searchParams.get('sort-by') as SortBy

if (assetClasses.length) {
filtered = filtered.filter((pool) => assetClasses.includes(toKebabCase(pool.assetClass)))
}

if (poolStatuses.length) {
filtered = filtered.filter((pool) => poolStatuses.includes(toKebabCase(pool.status)))
}

if (sortDirection && sortBy) {
filtered = sortData(filtered, sortBy, sortDirection)
}

return filtered
}

const sortMap = {
[SEARCH_KEYS.VALUE_LOCKED]: (item: PoolCardProps) => item.valueLocked.toNumber(),
[SEARCH_KEYS.APR]: (item: PoolCardProps) => (item.apr ? item.apr.toDecimal().toNumber() : 0),
}

function sortData(data: PoolCardProps[], sortBy: SortBy, sortDirection: SortDirection) {
if (sortMap.hasOwnProperty(sortBy)) {
const sortFunction = sortMap[sortBy]

data.sort((a, b) => {
const valueA = sortFunction(a)
const valueB = sortFunction(b)

return sortDirection === 'asc' ? compareNumeric(valueA, valueB) : compareNumeric(valueB, valueA)
})
}

return data
}

function compareNumeric(a: number, b: number) {
return a - b
}
Loading

0 comments on commit 3fa2791

Please sign in to comment.