Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 28 additions & 17 deletions apps/main/src/dex/features/pool-list/PoolListTable.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { isEqual } from 'lodash'
import { useCallback, useMemo, useState } from 'react'
import { type PoolListItem } from '@/dex/features/pool-list/types'
import { useNetworkFromUrl } from '@/dex/hooks/useChainId'
import { type NetworkConfig } from '@/dex/types/main.types'
import { notFalsy } from '@curvefi/prices-api/objects.util'
import { ColumnFiltersState, ExpandedState, getPaginationRowModel, useReactTable } from '@tanstack/react-table'
import { CurveApi } from '@ui-kit/features/connect-wallet'
import { useUserProfileStore } from '@ui-kit/features/user-profile'
import { SMALL_POOL_TVL } from '@ui-kit/features/user-profile/store'
import { MIN_POOLS_DISPLAYED, SMALL_POOL_TVL } from '@ui-kit/features/user-profile/store'
import { useIsTablet } from '@ui-kit/hooks/useBreakpoints'
import { usePageFromQueryString } from '@ui-kit/hooks/usePageFromQueryString'
import { useSortFromQueryString } from '@ui-kit/hooks/useSortFromQueryString'
Expand All @@ -16,6 +19,7 @@ import { EmptyStateRow } from '@ui-kit/shared/ui/DataTable/EmptyStateRow'
import { useColumnFilters } from '@ui-kit/shared/ui/DataTable/hooks/useColumnFilters'
import { TableFilters } from '@ui-kit/shared/ui/DataTable/TableFilters'
import { TableFiltersTitles } from '@ui-kit/shared/ui/DataTable/TableFiltersTitles'
import { minCutoffForTopK } from '@ui-kit/utils'
import { PoolListChips } from './chips/PoolListChips'
import { POOL_LIST_COLUMNS, PoolColumnId } from './columns'
import { PoolListEmptyState } from './components/PoolListEmptyState'
Expand All @@ -27,16 +31,24 @@ const LOCAL_STORAGE_KEY = 'dex-pool-list'

const migration: MigrationOptions<ColumnFiltersState> = { version: 1 }

const useDefaultPoolsFilter = (minLiquidity: number) =>
useMemo(
() => [
{
id: PoolColumnId.Tvl,
value: [minLiquidity, null],
},
],
[minLiquidity],
const useDefaultPoolsFilter = (data: PoolListItem[] | undefined) => {
const hideSmallPools = useUserProfileStore((s) => s.hideSmallPools)
const { hideSmallPoolsTvl: minTvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {}
return useMemo(
() =>
notFalsy(
data &&
hideSmallPools && {
id: PoolColumnId.Tvl,
value: [
minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), minTvl, MIN_POOLS_DISPLAYED),
null, // no upper limit
],
},
),
[data, minTvl, hideSmallPools],
)
}

const useSearch = (columnFiltersById: Record<string, unknown>, setColumnFilter: (id: string, value: unknown) => void) =>
[
Expand All @@ -45,22 +57,21 @@ const useSearch = (columnFiltersById: Record<string, unknown>, setColumnFilter:
] as const

const PER_PAGE = 50
const EMPTY: never[] = []

export const PoolListTable = ({ network, curve }: { network: NetworkConfig; curve: CurveApi | null }) => {
// todo: this needs to be more complicated, we need to show at least the top 10 per chain
const minLiquidity = useUserProfileStore((s) => s.hideSmallPools) ? SMALL_POOL_TVL : 0
const { isLite, poolFilters } = network
const { signerAddress } = curve ?? {}

// todo: use isReady to show a loading spinner close to the data
const { data, isLoading, isReady, userHasPositions } = usePoolListData(network)

const defaultFilters = useDefaultPoolsFilter(minLiquidity)
const [columnFilters, columnFiltersById, setColumnFilter, resetFilters] = useColumnFilters(
LOCAL_STORAGE_KEY,
const defaultFilters = useDefaultPoolsFilter(data)
const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({
title: LOCAL_STORAGE_KEY,
migration,
defaultFilters,
)
})
const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT)
const [pagination, onPaginationChange] = usePageFromQueryString(PER_PAGE)
const { columnSettings, columnVisibility, sortField } = usePoolListVisibilitySettings(LOCAL_STORAGE_KEY, {
Expand All @@ -71,7 +82,7 @@ export const PoolListTable = ({ network, curve }: { network: NetworkConfig; curv
const [searchText, onSearch] = useSearch(columnFiltersById, setColumnFilter)
const table = useReactTable({
columns: POOL_LIST_COLUMNS,
data: useMemo(() => data ?? [], [data]),
data: data ?? EMPTY,
state: { expanded, sorting, columnVisibility, columnFilters, pagination },
onSortingChange,
onExpandedChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CROSS_CHAIN_ADDRESSES } from '@/dex/constants'
import { POOL_TEXT_FIELDS } from '@/dex/features/pool-list/columns'
import { getUserActiveKey } from '@/dex/store/createUserSlice'
import useStore from '@/dex/store/useStore'
import type { NetworkConfig, PoolData } from '@/dex/types/main.types'
import { NetworkConfig, PoolData, PoolDataMapper } from '@/dex/types/main.types'
import { getPath } from '@/dex/utils/utilsRouter'
import { notFalsy, recordValues } from '@curvefi/prices-api/objects.util'
import { useConnection } from '@ui-kit/features/connect-wallet'
Expand All @@ -30,7 +30,7 @@ const getPoolTags = (hasPosition: boolean, { pool, pool: { address, id, name, re
export function usePoolListData({ id: network, chainId, isLite }: NetworkConfig) {
const { curveApi } = useConnection()
const userActiveKey = getUserActiveKey(curveApi)
const poolDataMapper = useStore((state) => state.pools.poolsMapper[chainId])
const poolDataMapper = useStore((state): PoolDataMapper | undefined => state.pools.poolsMapper[chainId])
const rewardsApyMapper = useStore((state) => state.pools.rewardsApyMapper[chainId])
const tvlMapper = useStore((state) => state.pools.tvlMapper[chainId])
const userPoolList = useStore((state) => state.user.poolList[userActiveKey])
Expand All @@ -45,7 +45,7 @@ export function usePoolListData({ id: network, chainId, isLite }: NetworkConfig)
)

usePageVisibleInterval(async () => {
if (curveApi && !isEmpty(rewardsApyMapper)) {
if (curveApi && !isEmpty(rewardsApyMapper) && poolsData) {
await fetchPoolsRewardsApy(chainId, poolsData)
}
}, REFRESH_INTERVAL['11m'])
Expand Down
11 changes: 7 additions & 4 deletions apps/main/src/dex/store/createPoolListSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { Chain } from '@curvefi/prices-api'
import { combineCampaigns } from '@ui-kit/entities/campaigns'
import { getCampaignsExternal } from '@ui-kit/entities/campaigns/campaigns-external'
import { getCampaignsMerkl } from '@ui-kit/entities/campaigns/campaigns-merkl'
import { MIN_POOLS_DISPLAYED } from '@ui-kit/features/user-profile/store'
import { groupSearchTerms, searchByText, takeTopWithMin } from '@ui-kit/utils'
import { fetchNetworks, getNetworks } from '../entities/networks'

Expand Down Expand Up @@ -153,10 +154,12 @@ const createPoolListSlice = (set: StoreApi<State>['setState'], get: StoreApi<Sta
filterSmallTvl: (poolDatas, tvlMapper, chainId) => {
const networks = getNetworks()
const { hideSmallPoolsTvl } = networks[chainId]

const result = takeTopWithMin(poolDatas, (pd) => +(tvlMapper?.[pd.pool.id]?.value || '0'), hideSmallPoolsTvl, 10)

return result
return takeTopWithMin(
poolDatas,
(pd) => +(tvlMapper?.[pd.pool.id]?.value || '0'),
hideSmallPoolsTvl,
MIN_POOLS_DISPLAYED,
)
},
sortFn: (sortKey, order, poolDatas, rewardsApyMapper, tvlMapper, volumeMapper, isCrvRewardsEnabled, chainId) => {
const networks = getNetworks()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ const useDefaultLlamaFilter = (minLiquidity: number) =>
[minLiquidity],
)

const migration: MigrationOptions<ColumnFiltersState> = {
version: 2,
// migration from v1 to v2: add deprecated filter
migrate: (oldValue, initial) => [...initial.filter((i) => !oldValue.some((o) => o.id === i.id)), ...oldValue],
}
const migration: MigrationOptions<ColumnFiltersState> = { version: 2 }
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for the migration anymore, we don't keep the defaultValues in local storage


const pagination = { pageIndex: 0, pageSize: 200 }

Expand All @@ -60,11 +56,11 @@ export const LlamaMarketsTable = ({

const minLiquidity = useUserProfileStore((s) => s.hideSmallPools) ? SMALL_POOL_TVL : 0
const defaultFilters = useDefaultLlamaFilter(minLiquidity)
const [columnFilters, columnFiltersById, setColumnFilter, resetFilters] = useColumnFilters(
LOCAL_STORAGE_KEY,
const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({
title: LOCAL_STORAGE_KEY,
migration,
defaultFilters,
)
})
const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT)
const { columnSettings, columnVisibility, toggleVisibility, sortField } = useLlamaTableVisibility(
LOCAL_STORAGE_KEY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ export const UserPositionsTable = ({ result, loading, tab }: UserPositionsTableP
const { markets: data = [], userHasPositions } = result ?? {}
const defaultFilters = useDefaultUserFilter(tab)
const title = LOCAL_STORAGE_KEYS[tab]
const [columnFilters, columnFiltersById, setColumnFilter, resetFilters] = useColumnFilters(
const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({
title,
migration,
defaultFilters,
)
})
const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT[tab], 'userSort')
const { columnSettings, columnVisibility, sortField } = useLlamaTableVisibility(title, sorting, tab)
const [expanded, onExpandedChange] = useState<ExpandedState>({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export const HideSmallPoolsSwitch = () => {
const hideSmallPools = useUserProfileStore((state) => state.hideSmallPools)
const setHideSmallPools = useUserProfileStore((state) => state.setHideSmallPools)
return (
<Switch checked={hideSmallPools} onChange={() => setHideSmallPools(!hideSmallPools)} color="primary" size="small" />
<Switch
checked={hideSmallPools}
onChange={() => setHideSmallPools(!hideSmallPools)}
color="primary"
size="small"
data-testid="small-pools-switch"
/>
)
}
1 change: 1 addition & 0 deletions packages/curve-ui-kit/src/features/user-profile/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { devtools, persist, type PersistOptions } from 'zustand/middleware'
import type { ThemeKey } from '@ui-kit/themes/basic-theme'

export const SMALL_POOL_TVL = 10000
export const MIN_POOLS_DISPLAYED = 10

export type UserProfileState = {
theme: ThemeKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export const HiddenMarketsResetFilters = ({
<Stack direction="row" gap={{ mobile: 2, tablet: 1 }} alignItems="center" sx={{ marginLeft: 'auto' }}>
<Stack direction="row" gap={1} alignItems="center">
<Typography variant="bodyXsRegular">{t`Hidden`}:</Typography>
<Typography variant="highlightS">{hiddenMarketCount ?? '-'}</Typography>
<Typography variant="highlightS" data-testid="hidden-market-count">
{hiddenMarketCount ?? '-'}
</Typography>
</Stack>
<ResetFiltersButton onClick={resetFilters} hidden={!hasFilters} />
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ const DEFAULT: ColumnFiltersState = []

/**
* A hook to manage filters for a table. Currently saved in the state, but the URL could be a better place.
* @param defaultFilters - The default filters to apply to the table.
* @param title - The title of the table, used as a key fdor local storage.
* @param migration - Migration options for the stored state.
* @return An object containing the current filters, a mapping of filters by column ID, a function to set a filter, and a function to reset filters.
*/
export function useColumnFilters(
tableTitle: string,
migration: MigrationOptions<ColumnFiltersState>,
defaultFilters: ColumnFiltersState = DEFAULT,
) {
const [columnFilters, setColumnFilters] = useTableFilters(tableTitle, defaultFilters, migration)
export function useColumnFilters({
title,
migration,
defaultFilters = DEFAULT,
}: {
title: string
migration: MigrationOptions<ColumnFiltersState>
defaultFilters?: ColumnFiltersState
}) {
const [storedFilters, setStoredFilters] = useTableFilters(title, DEFAULT, migration)
const setColumnFilter = useCallback(
(id: string, value: unknown) =>
setColumnFilters((filters) => [
setStoredFilters((filters) => [
...filters.filter((f) => f.id !== id),
...(value == null
? []
Expand All @@ -27,8 +35,13 @@ export function useColumnFilters(
},
]),
]),
[setColumnFilters],
[setStoredFilters],
)
const columnFilters = useMemo(() => {
const storedIds = new Set(storedFilters.map((f) => f.id))
return [...storedFilters, ...defaultFilters.filter((f) => !storedIds.has(f.id))]
}, [storedFilters, defaultFilters])

const columnFiltersById: Record<string, unknown> = useMemo(
() =>
columnFilters.reduce(
Expand All @@ -41,7 +54,6 @@ export function useColumnFilters(
[columnFilters],
)

const resetFilters = useCallback(() => setColumnFilters(defaultFilters), [defaultFilters, setColumnFilters])

return [columnFilters, columnFiltersById, setColumnFilter, resetFilters] as const
const resetFilters = useCallback(() => setStoredFilters(DEFAULT), [setStoredFilters])
return { columnFilters, columnFiltersById, setColumnFilter, resetFilters }
}
80 changes: 80 additions & 0 deletions packages/curve-ui-kit/src/utils/array.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { minCutoffForTopK, takeTopWithMin } from './array'

describe('takeTopWithMin and minCutoffForTopK', () => {
const id = (x: number) => x

;[
{
name: 'enough ≥ threshold → pure threshold filter',
items: [7, 5, 3, 1],
threshold: 4,
minCount: 2,
expectedTop: [7, 5],
expectedCutoff: 4,
},
{
name: 'not enough ≥ threshold → fallback to top-K',
items: [7, 5, 3, 1],
threshold: 6,
minCount: 2,
expectedTop: [7, 5],
expectedCutoff: 5,
},
{
name: 'duplicates at cutoff (fallback)',
items: [10, 9, 9, 8],
threshold: 9.5,
minCount: 2,
expectedTop: [10, 9],
expectedCutoff: 9,
},
{
name: 'threshold equal to duplicate value',
items: [10, 9, 9, 8],
threshold: 9,
minCount: 2,
expectedTop: [10, 9, 9],
expectedCutoff: 9,
},
{
name: 'minCount = 0 → pure threshold filter',
items: [10, 8, 7],
threshold: 9,
minCount: 0,
expectedTop: [10],
expectedCutoff: 9,
},
{
name: 'fewer items than minCount',
items: [10, 8],
threshold: 9,
minCount: 5,
expectedTop: [10, 8],
expectedCutoff: 8,
},
{
name: 'empty input',
items: [],
threshold: 9,
minCount: 3,
expectedTop: [],
expectedCutoff: 9,
},
{
name: 'negatives and decimals',
items: [-1.2, -3.4, 0.5],
threshold: -2,
minCount: 1,
expectedTop: [0.5, -1.2],
expectedCutoff: -2,
},
].forEach(({ name, items, threshold, minCount, expectedTop, expectedCutoff }) => {
it(name, () => {
const top = takeTopWithMin(items, id, threshold, minCount)
const cutoff = minCutoffForTopK(items, id, threshold, minCount)
expect(top).toEqual(expectedTop)
expect(cutoff).toBe(expectedCutoff)
})
})
})
33 changes: 33 additions & 0 deletions packages/curve-ui-kit/src/utils/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,36 @@ export function takeTopWithMin<T>(items: T[], getValue: (item: T) => number, thr
const sorted = orderBy(items, getValue, 'desc')
return takeWhile(sorted, (_item, index) => getValue(sorted[index]) >= threshold || index < minCount)
}

/**
* Computes a cutoff value that guarantees at least `minCount` items pass.
* - This function is the numeric counterpart of `takeTopWithMin`.
*
* Behavior:
* - If at least `minCount` items have value ≥ `threshold`, returns `threshold` unchanged.
* - Otherwise, returns the `minCount`-th largest value (the fallback cutoff so that ≥ `minCount` items pass).
* If fewer than `minCount` items exist, returns the smallest value.
*
* This is useful to build a filter that prefers a fixed threshold, while
* still ensuring a minimum number of items are shown when the data is sparse.
*
* @template T - The type of items in the array.
* @param items - The array of items to inspect.
* @param getValue - Getter that maps an item to a numeric value.
* @param threshold - Preferred minimum value for passing items.
* @param minCount - Minimum number of items that must pass.
* @returns The cutoff value to use for filtering.
*/
export function minCutoffForTopK<T>(
items: T[],
getValue: (item: T) => number,
threshold: number,
minCount: number,
): number {
const valuesDesc = orderBy(items.map(getValue), undefined, 'desc')
const firstBelowIdx = valuesDesc.findIndex((v) => v < threshold)
if (firstBelowIdx === -1 || firstBelowIdx >= minCount) return threshold
// we have fewer than minCount items above threshold, find the minCount-th value
const count = minCount >= valuesDesc.length ? valuesDesc.length : minCount
return valuesDesc[count - 1]
}
Loading
Loading