Skip to content

Commit 611b121

Browse files
kanerepmintdart
andauthored
feat: top protocols column, chain filters and search (#2260)
* feat: add search, chain and column filter to top-protocols * adjust position of csv button * empty state and type issue * refactor: create usePersistentColumnVisibility hook * refactor * fix default selected chains * refactor * use URL query params * remove hook --------- Co-authored-by: mintdart <[email protected]>
1 parent 338f7d0 commit 611b121

File tree

2 files changed

+283
-30
lines changed

2 files changed

+283
-30
lines changed

src/contexts/LocalStorage.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable no-unused-vars*/
22
import { useEffect, useMemo, useSyncExternalStore } from 'react'
3-
import toast from 'react-hot-toast'
43
import { useIsClient } from '~/hooks'
54
import { slug } from '~/utils'
65
import { getThemeCookie, setThemeCookie } from '~/utils/cookies'

src/pages/top-protocols.tsx

Lines changed: 283 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,57 @@
11
import * as React from 'react'
2-
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
2+
import { useRouter } from 'next/router'
3+
import {
4+
ColumnFiltersState,
5+
createColumnHelper,
6+
getCoreRowModel,
7+
getFilteredRowModel,
8+
useReactTable
9+
} from '@tanstack/react-table'
310
import { maxAgeForNext } from '~/api'
411
import { getSimpleProtocolsPageData } from '~/api/categories/protocols'
512
import { CSVDownloadButton } from '~/components/ButtonStyled/CsvButton'
13+
import { Icon } from '~/components/Icon'
614
import { BasicLink } from '~/components/Link'
15+
import { SelectWithCombobox } from '~/components/SelectWithCombobox'
716
import { VirtualTable } from '~/components/Table/Table'
817
import { TokenLogo } from '~/components/TokenLogo'
18+
import { DEFI_SETTINGS_KEYS } from '~/contexts/LocalStorage'
19+
import { useDebounce } from '~/hooks/useDebounce'
920
import Layout from '~/layout'
1021
import { chainIconUrl, slug } from '~/utils'
1122
import { withPerformanceLogging } from '~/utils/perf'
1223
import { descriptions } from './categories'
1324

25+
const excludeChains = new Set([...DEFI_SETTINGS_KEYS, 'offers', 'dcAndLsOverlap', 'excludeParent'])
26+
const excludeCategories = new Set(['Bridge', 'Canonical Bridge'])
1427
export const getStaticProps = withPerformanceLogging('top-protocols', async () => {
1528
const { protocols, chains } = await getSimpleProtocolsPageData(['name', 'extraTvl', 'chainTvls', 'category'])
16-
const topProtocolPerChainAndCategory = Object.fromEntries(chains.map((c) => [c, {}]))
29+
const topProtocolPerChainAndCategory = {}
1730

18-
protocols.forEach((p) => {
31+
for (const p of protocols) {
1932
const { chainTvls, category, name } = p
20-
if (['Bridge', 'Canonical Bridge'].includes(category)) {
21-
return
33+
if (excludeCategories.has(category)) {
34+
continue
2235
}
23-
Object.entries(chainTvls ?? {}).forEach(([chain, { tvl }]: [string, { tvl: number }]) => {
24-
if (topProtocolPerChainAndCategory[chain] === undefined) {
25-
return
36+
for (const chain in chainTvls) {
37+
if (chain.includes('-') || excludeChains.has(chain)) {
38+
continue
2639
}
40+
const tvl = chainTvls[chain].tvl
41+
topProtocolPerChainAndCategory[chain] = topProtocolPerChainAndCategory[chain] ?? {}
2742

2843
const currentTopProtocol = topProtocolPerChainAndCategory[chain][category]
2944

30-
if (currentTopProtocol === undefined || tvl > currentTopProtocol[1]) {
45+
if (currentTopProtocol == null || tvl > currentTopProtocol[1]) {
3146
topProtocolPerChainAndCategory[chain][category] = [name, tvl]
3247
}
33-
})
34-
})
48+
}
49+
}
3550

3651
const data = []
3752
const uniqueCategories = new Set()
3853

39-
chains.forEach((chain) => {
54+
for (const chain of chains) {
4055
const categories = topProtocolPerChainAndCategory[chain]
4156
const values = {}
4257

@@ -45,11 +60,12 @@ export const getStaticProps = withPerformanceLogging('top-protocols', async () =
4560
values[cat] = categories[cat][0]
4661
}
4762
data.push({ chain, ...values })
48-
})
63+
}
4964

5065
return {
5166
props: {
5267
data,
68+
chains: data.map((row) => row.chain),
5369
uniqueCategories: Array.from(uniqueCategories)
5470
},
5571
revalidate: maxAgeForNext([22])
@@ -58,14 +74,49 @@ export const getStaticProps = withPerformanceLogging('top-protocols', async () =
5874

5975
const pageName = ['Top Protocols']
6076

61-
export default function TopProtocols({ data, uniqueCategories }) {
62-
const columns = React.useMemo(() => {
63-
const columnHelper = createColumnHelper<any>()
77+
export default function TopProtocols({ data, chains, uniqueCategories }) {
78+
const columnHelper = React.useMemo(() => createColumnHelper<any>(), [])
79+
80+
const columnOptions = React.useMemo(
81+
() => uniqueCategories.map((cat) => ({ name: cat, key: cat })),
82+
[uniqueCategories]
83+
)
6484

85+
const router = useRouter()
86+
const { selectedChains, selectedColumns, columnVisibility } = React.useMemo(() => {
87+
const { chain, column } = router.query
88+
const selectedChains = chain ? (Array.isArray(chain) ? chain : chain == 'All' ? chains : [chain]) : chains
89+
const selectedColumns = column
90+
? Array.isArray(column)
91+
? column
92+
: column == 'All'
93+
? uniqueCategories
94+
: [column]
95+
: uniqueCategories
96+
const selectedColumnsSet = new Set(selectedColumns)
97+
const columnVisibility = {}
98+
for (const col of uniqueCategories) {
99+
columnVisibility[col] = selectedColumnsSet.has(col)
100+
}
101+
return { selectedChains, selectedColumns, columnVisibility }
102+
}, [router.query, chains, uniqueCategories])
103+
104+
const columns = React.useMemo(() => {
65105
const baseColumns = [
66106
columnHelper.accessor('chain', {
67107
header: 'Chain',
68108
enableSorting: false,
109+
filterFn: (row, id, filterValue) => {
110+
const value = (row.getValue(id) as string) ?? ''
111+
if (!filterValue || typeof filterValue !== 'object') {
112+
return true
113+
}
114+
const { search = '', selected = [] } = filterValue as { search?: string; selected?: string[] }
115+
const normalizedValue = value.toLowerCase()
116+
const matchesSearch = search ? normalizedValue.includes(search.toLowerCase()) : true
117+
const matchesSelection = Array.isArray(selected) && selected.length > 0 ? selected.includes(value) : true
118+
return matchesSearch && matchesSelection
119+
},
69120
cell: (info) => {
70121
const chain = info.getValue()
71122
const rowIndex = info.row.index
@@ -106,28 +157,172 @@ export default function TopProtocols({ data, uniqueCategories }) {
106157
)
107158

108159
return [...baseColumns, ...categoryColumns]
109-
}, [uniqueCategories])
160+
}, [columnHelper, uniqueCategories])
161+
162+
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
163+
const [searchValue, setSearchValue] = React.useState('')
164+
const debouncedSearch = useDebounce(searchValue, 200)
110165

111166
const table = useReactTable({
112167
data,
113168
columns,
114-
115-
getCoreRowModel: getCoreRowModel()
169+
state: {
170+
columnFilters,
171+
columnVisibility
172+
},
173+
onColumnFiltersChange: setColumnFilters,
174+
getCoreRowModel: getCoreRowModel(),
175+
getFilteredRowModel: getFilteredRowModel()
116176
})
117177

178+
React.useEffect(() => {
179+
const column = table.getColumn('chain')
180+
if (!column) return
181+
182+
column.setFilterValue({ search: debouncedSearch, selected: selectedChains })
183+
}, [debouncedSearch, selectedChains, table])
184+
185+
const clearChainSelection = React.useCallback(() => {
186+
const { chain, ...queries } = router.query
187+
router.push(
188+
{
189+
pathname: router.pathname,
190+
query: { ...queries, chain: 'None' }
191+
},
192+
undefined,
193+
{ shallow: true }
194+
)
195+
}, [router])
196+
197+
const toggleAllChains = React.useCallback(() => {
198+
const { chain, ...queries } = router.query
199+
router.push(
200+
{
201+
pathname: router.pathname,
202+
query: queries
203+
},
204+
undefined,
205+
{ shallow: true }
206+
)
207+
}, [router])
208+
209+
const addChain = React.useCallback(
210+
(newOptions: Array<string>) => {
211+
const { chain, ...queries } = router.query
212+
router.push(
213+
{
214+
pathname: router.pathname,
215+
query: {
216+
...queries,
217+
chain: newOptions
218+
}
219+
},
220+
undefined,
221+
{ shallow: true }
222+
)
223+
},
224+
[router]
225+
)
226+
227+
const selectOnlyOneChain = React.useCallback(
228+
(chain: string) => {
229+
const { chain: currentChain, ...queries } = router.query
230+
router.push(
231+
{
232+
pathname: router.pathname,
233+
query: {
234+
...queries,
235+
chain: chain
236+
}
237+
},
238+
undefined,
239+
{ shallow: true }
240+
)
241+
},
242+
[router]
243+
)
244+
245+
const clearAllColumns = React.useCallback(() => {
246+
const { column, ...queries } = router.query
247+
router.push(
248+
{
249+
pathname: router.pathname,
250+
query: {
251+
...queries,
252+
column: 'None'
253+
}
254+
},
255+
undefined,
256+
{ shallow: true }
257+
)
258+
}, [router])
259+
260+
const toggleAllColumns = React.useCallback(() => {
261+
const { column, ...queries } = router.query
262+
router.push(
263+
{
264+
pathname: router.pathname,
265+
query: queries
266+
},
267+
undefined,
268+
{ shallow: true }
269+
)
270+
}, [router])
271+
272+
const addColumn = React.useCallback(
273+
(newOptions: Array<string>) => {
274+
const { column, ...queries } = router.query
275+
router.push(
276+
{
277+
pathname: router.pathname,
278+
query: {
279+
...queries,
280+
column: newOptions
281+
}
282+
},
283+
undefined,
284+
{ shallow: true }
285+
)
286+
},
287+
[router]
288+
)
289+
290+
const addOnlyOneColumn = React.useCallback(
291+
(newOption: string) => {
292+
const { column, ...queries } = router.query
293+
router.push(
294+
{
295+
pathname: router.pathname,
296+
query: {
297+
...queries,
298+
column: newOption
299+
}
300+
},
301+
undefined,
302+
{ shallow: true }
303+
)
304+
},
305+
[router]
306+
)
307+
118308
const prepareCsv = React.useCallback(() => {
119-
const headers = ['Chain', ...uniqueCategories]
120-
const csvData = data.map((row) => {
121-
return {
122-
Chain: row.chain,
123-
...Object.fromEntries(uniqueCategories.map((cat) => [cat, row[cat] || '']))
309+
const visibleColumns = table.getAllLeafColumns().filter((col) => col.getIsVisible())
310+
const headers = visibleColumns.map((col) => {
311+
if (typeof col.columnDef.header === 'string') {
312+
return col.columnDef.header
124313
}
314+
return col.id
125315
})
126316

127-
const rows = [headers, ...csvData.map((row) => headers.map((header) => row[header]))]
317+
const dataRows = table.getFilteredRowModel().rows.map((row) =>
318+
visibleColumns.map((col) => {
319+
const value = row.getValue(col.id)
320+
return value ?? ''
321+
})
322+
)
128323

129-
return { filename: 'top-protocols.csv', rows: rows as (string | number | boolean)[][] }
130-
}, [data, uniqueCategories])
324+
return { filename: 'top-protocols.csv', rows: [headers, ...dataRows] as (string | number | boolean)[][] }
325+
}, [table])
131326

132327
return (
133328
<Layout
@@ -139,9 +334,68 @@ export default function TopProtocols({ data, uniqueCategories }) {
139334
>
140335
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-(--cards-bg) bg-(--cards-bg) p-3">
141336
<h1 className="mr-auto text-xl font-semibold">Protocols with highest TVL by chain on each category</h1>
142-
<CSVDownloadButton prepareCsv={prepareCsv} smol />
143337
</div>
144-
<VirtualTable instance={table} />
338+
<div className="isolate rounded-md border border-(--cards-border) bg-(--cards-bg)">
339+
<div className="flex flex-wrap items-center justify-end gap-2 p-2">
340+
<label className="relative mr-auto w-full sm:max-w-[280px]">
341+
<span className="sr-only">Search chains</span>
342+
<Icon
343+
name="search"
344+
height={16}
345+
width={16}
346+
className="absolute top-0 bottom-0 left-2 my-auto text-(--text-tertiary)"
347+
/>
348+
<input
349+
value={searchValue}
350+
onChange={(e) => {
351+
setSearchValue(e.target.value)
352+
}}
353+
placeholder="Search..."
354+
className="w-full rounded-md border border-(--form-control-border) bg-white p-1 pl-7 text-black max-sm:py-0.5 dark:bg-black dark:text-white"
355+
/>
356+
</label>
357+
358+
<div className="flex items-center gap-2 max-sm:w-full max-sm:flex-col">
359+
<SelectWithCombobox
360+
allValues={chains}
361+
selectedValues={selectedChains}
362+
setSelectedValues={addChain}
363+
selectOnlyOne={selectOnlyOneChain}
364+
toggleAll={toggleAllChains}
365+
clearAll={clearChainSelection}
366+
nestedMenu={false}
367+
label={'Chains'}
368+
labelType="smol"
369+
triggerProps={{
370+
className:
371+
'flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-md cursor-pointer flex-nowrap relative border border-(--form-control-border) text-(--text-form) hover:bg-(--link-hover-bg) focus-visible:bg-(--link-hover-bg) font-medium w-full sm:w-auto'
372+
}}
373+
/>
374+
375+
<SelectWithCombobox
376+
allValues={columnOptions}
377+
selectedValues={selectedColumns}
378+
setSelectedValues={addColumn}
379+
selectOnlyOne={addOnlyOneColumn}
380+
toggleAll={toggleAllColumns}
381+
clearAll={clearAllColumns}
382+
nestedMenu={false}
383+
label={'Columns'}
384+
labelType="smol"
385+
triggerProps={{
386+
className:
387+
'flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-md cursor-pointer flex-nowrap relative border border-(--form-control-border) text-(--text-form) hover:bg-(--link-hover-bg) focus-visible:bg-(--link-hover-bg) font-medium w-full sm:w-auto'
388+
}}
389+
/>
390+
<CSVDownloadButton prepareCsv={prepareCsv} smol />
391+
</div>
392+
</div>
393+
{table.getFilteredRowModel().rows.length > 0 ? (
394+
<VirtualTable instance={table} />
395+
) : (
396+
<p className="px-3 py-6 text-center text-(--text-primary)">No results found</p>
397+
)}
398+
</div>
145399
</Layout>
146400
)
147401
}

0 commit comments

Comments
 (0)