From 7f804003825cf7014e00c3e3a7cd783c14443492 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 4 Nov 2025 16:58:32 +0100 Subject: [PATCH 01/10] fix: small pools filter showing at least 10 --- .../dex/features/pool-list/PoolListTable.tsx | 48 +++++++++++-------- .../main/src/dex/store/createPoolListSlice.ts | 11 +++-- .../market-list/LlamaMarketsTable.tsx | 6 +-- .../market-list/UserPositionsTable.tsx | 4 +- .../src/features/user-profile/store.ts | 1 + .../ui/DataTable/hooks/useColumnFilters.tsx | 34 ++++++++----- 6 files changed, 65 insertions(+), 39 deletions(-) diff --git a/apps/main/src/dex/features/pool-list/PoolListTable.tsx b/apps/main/src/dex/features/pool-list/PoolListTable.tsx index decb0bc7be..89cde167ca 100644 --- a/apps/main/src/dex/features/pool-list/PoolListTable.tsx +++ b/apps/main/src/dex/features/pool-list/PoolListTable.tsx @@ -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' @@ -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 { takeTopWithMin } from '@ui-kit/utils' import { PoolListChips } from './chips/PoolListChips' import { POOL_LIST_COLUMNS, PoolColumnId } from './columns' import { PoolListEmptyState } from './components/PoolListEmptyState' @@ -27,16 +31,23 @@ const LOCAL_STORAGE_KEY = 'dex-pool-list' const migration: MigrationOptions = { version: 1 } -const useDefaultPoolsFilter = (minLiquidity: number) => - useMemo( - () => [ - { - id: PoolColumnId.Tvl, - value: [minLiquidity, null], - }, - ], - [minLiquidity], +const useStaticPoolsFilter = (data: PoolListItem[]) => { + const hideSmallPools = useUserProfileStore((s) => s.hideSmallPools) + const { hideSmallPoolsTvl: tvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {} + return useMemo( + () => + notFalsy( + hideSmallPools && { + id: PoolColumnId.Tvl, + value: [ + takeTopWithMin(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), + null, // no upper limit + ], + }, + ), + [data, tvl, hideSmallPools], ) +} const useSearch = (columnFiltersById: Record, setColumnFilter: (id: string, value: unknown) => void) => [ @@ -45,22 +56,21 @@ const useSearch = (columnFiltersById: Record, 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 staticFilters = useStaticPoolsFilter(data) + const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({ + title: LOCAL_STORAGE_KEY, migration, - defaultFilters, - ) + staticFilters, + }) const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT) const [pagination, onPaginationChange] = usePageFromQueryString(PER_PAGE) const { columnSettings, columnVisibility, sortField } = usePoolListVisibilitySettings(LOCAL_STORAGE_KEY, { @@ -71,7 +81,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, @@ -109,7 +119,7 @@ export const PoolListTable = ({ network, curve }: { network: NetworkConfig; curv 0 && !isEqual(columnFilters, defaultFilters)} + hasFilters={columnFilters.length > 0 && !isEqual(columnFilters, staticFilters)} resetFilters={resetFilters} onSortingChange={onSortingChange} sortField={sortField} diff --git a/apps/main/src/dex/store/createPoolListSlice.ts b/apps/main/src/dex/store/createPoolListSlice.ts index 608de8ed16..22a916941d 100644 --- a/apps/main/src/dex/store/createPoolListSlice.ts +++ b/apps/main/src/dex/store/createPoolListSlice.ts @@ -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' @@ -153,10 +154,12 @@ const createPoolListSlice = (set: StoreApi['setState'], get: StoreApi { 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() diff --git a/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx b/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx index 0d8a80ed26..55ef2d368a 100644 --- a/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx +++ b/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx @@ -58,11 +58,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, diff --git a/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx b/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx index 6c32d7088c..16c7446af8 100644 --- a/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx +++ b/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx @@ -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({}) diff --git a/packages/curve-ui-kit/src/features/user-profile/store.ts b/packages/curve-ui-kit/src/features/user-profile/store.ts index d4e6239cf1..69ad64e783 100644 --- a/packages/curve-ui-kit/src/features/user-profile/store.ts +++ b/packages/curve-ui-kit/src/features/user-profile/store.ts @@ -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 diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx index f53315ef56..56cad3f224 100644 --- a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx +++ b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx @@ -7,16 +7,27 @@ 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 staticFilters - Filters that cannot be changed by the user and are not stored in local storage. + * @param title - The title of the table, used as a key for 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, - defaultFilters: ColumnFiltersState = DEFAULT, -) { - const [columnFilters, setColumnFilters] = useTableFilters(tableTitle, defaultFilters, migration) +export function useColumnFilters({ + title, + migration, + defaultFilters = DEFAULT, + staticFilters = DEFAULT, +}: { + title: string + migration: MigrationOptions + defaultFilters?: ColumnFiltersState + staticFilters?: ColumnFiltersState +}) { + const [storedFilters, setStoredFilters] = useTableFilters(title, defaultFilters, migration) const setColumnFilter = useCallback( (id: string, value: unknown) => - setColumnFilters((filters) => [ + setStoredFilters((filters) => [ ...filters.filter((f) => f.id !== id), ...(value == null ? [] @@ -27,8 +38,10 @@ export function useColumnFilters( }, ]), ]), - [setColumnFilters], + [setStoredFilters], ) + const columnFilters = useMemo(() => [...storedFilters, ...staticFilters], [storedFilters, staticFilters]) + const columnFiltersById: Record = useMemo( () => columnFilters.reduce( @@ -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(defaultFilters), [defaultFilters, setStoredFilters]) + return { columnFilters, columnFiltersById, setColumnFilter, resetFilters } } From af31896381c39443da9ccf1f95ccc7f485242824 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 4 Nov 2025 18:05:17 +0100 Subject: [PATCH 02/10] feat: don't save default filters on storage --- .../main/src/dex/features/pool-list/PoolListTable.tsx | 8 ++++---- .../shared/ui/DataTable/hooks/useColumnFilters.tsx | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/main/src/dex/features/pool-list/PoolListTable.tsx b/apps/main/src/dex/features/pool-list/PoolListTable.tsx index 89cde167ca..b0b49c60a9 100644 --- a/apps/main/src/dex/features/pool-list/PoolListTable.tsx +++ b/apps/main/src/dex/features/pool-list/PoolListTable.tsx @@ -31,7 +31,7 @@ const LOCAL_STORAGE_KEY = 'dex-pool-list' const migration: MigrationOptions = { version: 1 } -const useStaticPoolsFilter = (data: PoolListItem[]) => { +const useDefaultPoolsFilter = (data: PoolListItem[]) => { const hideSmallPools = useUserProfileStore((s) => s.hideSmallPools) const { hideSmallPoolsTvl: tvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {} return useMemo( @@ -65,11 +65,11 @@ export const PoolListTable = ({ network, curve }: { network: NetworkConfig; curv // todo: use isReady to show a loading spinner close to the data const { data, isLoading, isReady, userHasPositions } = usePoolListData(network) - const staticFilters = useStaticPoolsFilter(data) + const defaultFilters = useDefaultPoolsFilter(data) const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({ title: LOCAL_STORAGE_KEY, migration, - staticFilters, + defaultFilters, }) const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT) const [pagination, onPaginationChange] = usePageFromQueryString(PER_PAGE) @@ -119,7 +119,7 @@ export const PoolListTable = ({ network, curve }: { network: NetworkConfig; curv 0 && !isEqual(columnFilters, staticFilters)} + hasFilters={columnFilters.length > 0 && !isEqual(columnFilters, defaultFilters)} resetFilters={resetFilters} onSortingChange={onSortingChange} sortField={sortField} diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx index 56cad3f224..76eab5833e 100644 --- a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx +++ b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx @@ -17,14 +17,12 @@ export function useColumnFilters({ title, migration, defaultFilters = DEFAULT, - staticFilters = DEFAULT, }: { title: string migration: MigrationOptions defaultFilters?: ColumnFiltersState - staticFilters?: ColumnFiltersState }) { - const [storedFilters, setStoredFilters] = useTableFilters(title, defaultFilters, migration) + const [storedFilters, setStoredFilters] = useTableFilters(title, DEFAULT, migration) const setColumnFilter = useCallback( (id: string, value: unknown) => setStoredFilters((filters) => [ @@ -40,7 +38,10 @@ export function useColumnFilters({ ]), [setStoredFilters], ) - const columnFilters = useMemo(() => [...storedFilters, ...staticFilters], [storedFilters, staticFilters]) + 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 = useMemo( () => @@ -54,6 +55,6 @@ export function useColumnFilters({ [columnFilters], ) - const resetFilters = useCallback(() => setStoredFilters(defaultFilters), [defaultFilters, setStoredFilters]) + const resetFilters = useCallback(() => setStoredFilters(DEFAULT), [setStoredFilters]) return { columnFilters, columnFiltersById, setColumnFilter, resetFilters } } From 8e6d18b200178b01c0b71ddb3dbad6e2c48ef666 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Nov 2025 10:32:41 +0100 Subject: [PATCH 03/10] e2e: remove migration, check hidden markets --- .../market-list/LlamaMarketsTable.tsx | 6 +- .../DataTable/HiddenMarketsResetFilters.tsx | 4 +- .../e2e/llamalend/llamalend-markets.cy.ts | 16 +- tests/cypress/e2e/main/dex-markets.cy.ts | 244 ++++++++++-------- 4 files changed, 137 insertions(+), 133 deletions(-) diff --git a/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx b/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx index 55ef2d368a..fc45ca462a 100644 --- a/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx +++ b/apps/main/src/llamalend/features/market-list/LlamaMarketsTable.tsx @@ -35,11 +35,7 @@ const useDefaultLlamaFilter = (minLiquidity: number) => [minLiquidity], ) -const migration: MigrationOptions = { - 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 = { version: 2 } const pagination = { pageIndex: 0, pageSize: 200 } diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/HiddenMarketsResetFilters.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/HiddenMarketsResetFilters.tsx index 3ba6553c74..dae1146370 100644 --- a/packages/curve-ui-kit/src/shared/ui/DataTable/HiddenMarketsResetFilters.tsx +++ b/packages/curve-ui-kit/src/shared/ui/DataTable/HiddenMarketsResetFilters.tsx @@ -30,7 +30,9 @@ export const HiddenMarketsResetFilters = ({ {t`Hidden`}: - {hiddenMarketCount ?? '-'} + + {hiddenMarketCount ?? '-'} + diff --git a/tests/cypress/e2e/llamalend/llamalend-markets.cy.ts b/tests/cypress/e2e/llamalend/llamalend-markets.cy.ts index f28218229d..1e210fdc7d 100644 --- a/tests/cypress/e2e/llamalend/llamalend-markets.cy.ts +++ b/tests/cypress/e2e/llamalend/llamalend-markets.cy.ts @@ -10,7 +10,7 @@ import { mockLendingSnapshots, mockLendingVaults, } from '@cy/support/helpers/lending-mocks' -import { LLAMA_FILTERS_V1, LLAMA_VISIBILITY_SETTINGS_V0 } from '@cy/support/helpers/llamalend-storage' +import { LLAMA_VISIBILITY_SETTINGS_V0 } from '@cy/support/helpers/llamalend-storage' import { mockMintMarkets, mockMintSnapshots } from '@cy/support/helpers/minting-mocks' import { mockTokenPrices } from '@cy/support/helpers/tokens' import { @@ -322,20 +322,6 @@ describe(`LlamaLend Storage Migration`, () => { setupMocks() }) - it('migrates old filter to remove deprecated markets', () => { - visitAndWait(oneViewport(), { - onBeforeLoad({ localStorage }) { - localStorage.clear() - localStorage.setItem('table-filters-llamalend-markets-v1', JSON.stringify(LLAMA_FILTERS_V1)) - }, - }) - cy.window().then(({ localStorage }) => { - expect(localStorage.getItem('table-filters-llamalend-markets-v1')).to.be.null - const newValue = JSON.parse(localStorage.getItem('table-filters-llamalend-markets-v2')!) - expect(newValue).to.deep.equal([{ id: 'deprecatedMessage', value: false }, ...LLAMA_FILTERS_V1]) - }) - }) - it('migrates old visibility settings', () => { visitAndWait(oneViewport(), { onBeforeLoad({ localStorage }) { diff --git a/tests/cypress/e2e/main/dex-markets.cy.ts b/tests/cypress/e2e/main/dex-markets.cy.ts index f970b7a9e9..a2e9d70a32 100644 --- a/tests/cypress/e2e/main/dex-markets.cy.ts +++ b/tests/cypress/e2e/main/dex-markets.cy.ts @@ -1,7 +1,7 @@ import { orderBy } from 'lodash' import { oneOf } from '@cy/support/generators' import { setShowSmallPools } from '@cy/support/helpers/user-profile' -import { API_LOAD_TIMEOUT, type Breakpoint, LOAD_TIMEOUT, oneViewport } from '@cy/support/ui' +import { API_LOAD_TIMEOUT, type Breakpoint, LOAD_TIMEOUT, oneMobileViewport } from '@cy/support/ui' const PATH = '/dex/arbitrum/pools/' @@ -48,130 +48,150 @@ const getTopUsdValues = (columnId: 'volume' | 'tvl') => ), ) -describe('DEX Pools', () => { - let breakpoint: Breakpoint - let width: number, height: number +;[[...oneMobileViewport(), 'mobile'] as const].forEach((viewport) => { + describe('DEX Pools ' + viewport[2], () => { + let breakpoint: Breakpoint + let width: number, height: number - beforeEach(() => { - ;[width, height, breakpoint] = oneViewport() - }) - - describe('First page', () => { - beforeEach(() => visitAndWait(width, height)) - - function sortBy(field: string, expectedOrder: 'asc' | 'desc' | false) { - if (breakpoint === 'mobile') { - cy.get('[data-testid="btn-drawer-sort-dex-pools"]').click() - cy.get(`[data-testid="drawer-sort-menu-dex-pools"] li[value="${field}"]`).click() - cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.be.visible') - } else { - cy.get(`[data-testid="data-table-header-${field}"]`).click() - cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.exist') - } - if (expectedOrder) { - cy.get(`[data-testid="icon-sort-${field}-${expectedOrder}"]`).should('be.visible') - } else { - cy.get(`[data-testid^="icon-sort-${field}"]`).should('not.exist') - } - } + beforeEach(() => { + ;[width, height, breakpoint] = viewport // oneViewport() + }) - function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { - if (isMobile) { - cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') + describe('First page', () => { + beforeEach(() => visitAndWait(width, height)) + + function sortBy(field: string, expectedOrder: 'asc' | 'desc' | false) { + if (breakpoint === 'mobile') { + cy.get('[data-testid="btn-drawer-sort-dex-pools"]').click() + cy.get(`[data-testid="drawer-sort-menu-dex-pools"] li[value="${field}"]`).click() + cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.be.visible') + } else { + cy.get(`[data-testid="data-table-header-${field}"]`).click() + cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.exist') + } + if (expectedOrder) { + cy.get(`[data-testid="icon-sort-${field}-${expectedOrder}"]`).should('be.visible') + } else { + cy.get(`[data-testid^="icon-sort-${field}"]`).should('not.exist') + } } - cy.get(`[data-testid="filter-chip-${chip}"]`).click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') - } - it('sorts by volume', () => { - getTopUsdValues('volume').then((vals) => expectOrder(vals, 'desc')) // initial is Volume desc - cy.url().should('not.include', 'volume') // initial sort not in URL - if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment - sortBy('volume', 'asc') - getTopUsdValues('volume').then((vals) => expectOrder(vals, 'asc')) - cy.url().should('include', 'sort=volume') - }) + it('sorts by volume', () => { + getTopUsdValues('volume').then((vals) => expectOrder(vals, 'desc')) // initial is Volume desc + cy.url().should('not.include', 'volume') // initial sort not in URL + if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment + sortBy('volume', 'asc') + getTopUsdValues('volume').then((vals) => expectOrder(vals, 'asc')) + cy.url().should('include', 'sort=volume') + }) - it('sorts by TVL (desc/asc)', () => { - cy.url().should('not.include', 'tvl') // initial sort not in URL - sortBy('tvl', 'desc') - getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'desc')) - cy.url().should('include', 'sort=-tvl') - if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment - sortBy('tvl', 'asc') - getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'asc')) - cy.url().should('include', 'sort=tvl') - }) + it('sorts by TVL (desc/asc)', () => { + cy.url().should('not.include', 'tvl') // initial sort not in URL + sortBy('tvl', 'desc') + getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'desc')) + cy.url().should('include', 'sort=-tvl') + if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment + sortBy('tvl', 'asc') + getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'asc')) + cy.url().should('include', 'sort=tvl') + }) - it('filters by currency chip', () => { - const currency = oneOf('usd', 'btc') - cy.get('[data-testid^="data-table-row-"]').then(($before) => { - const beforeCount = $before.length - clickFilterChip(currency) - cy.get('[data-testid^="data-table-row-"]').then(($after) => { - const afterCount = $after.length - expect(afterCount).to.be.lessThan(beforeCount) - // chip is in the drawer for mobile, check on desktop only - if (breakpoint !== 'mobile') cy.get(`[data-testid="filter-chip-${currency}"]`).contains(`(${afterCount})`) + it('filters by currency chip', () => { + const currency = oneOf('usd', 'btc') + const getHiddenCount = () => + withFilterChips(() => cy.get('[data-testid="hidden-market-count"]').then(([{ innerText }]) => innerText)) + + getHiddenCount().then((beforeCount) => { + expect(isNaN(+beforeCount), `Cannot parse hidden count ${beforeCount}`).to.be.false + clickFilterChip(currency) + getHiddenCount().then((afterCount) => { + expect(+afterCount).to.be.greaterThan(+beforeCount) + // chip is in the drawer for mobile, check on desktop that we show count + if (breakpoint !== 'mobile') cy.get(`[data-testid="filter-chip-${currency}"]`).contains(/\(\d+\)/) + }) + cy.get('[data-testid="data-table-cell-PoolName"]').contains(currency.toUpperCase()) }) - cy.get('[data-testid="data-table-cell-PoolName"]').contains(currency.toUpperCase()) }) - }) - it('navigates to pool deposit page by clicking a row', () => { - cy.get('[data-testid^="data-table-row-"]').first().click() - if (breakpoint === 'mobile') { - cy.get('[data-testid="collapse-icon"]').first().should('be.visible') - cy.get('[data-testid="pool-link-deposit"]').click() - } - cy.url(LOAD_TIMEOUT).should('match', /\/dex\/arbitrum\/pools\/[^/]+\/(deposit|swap)\/?$/) - cy.title().should('match', /Curve - Pool - .* - Curve/) + it('navigates to pool deposit page by clicking a row', () => { + cy.get('[data-testid^="data-table-row-"]').first().click() + if (breakpoint === 'mobile') { + cy.get('[data-testid="collapse-icon"]').first().should('be.visible') + cy.get('[data-testid="pool-link-deposit"]').click() + } + cy.url(LOAD_TIMEOUT).should('match', /\/dex\/arbitrum\/pools\/[^/]+\/(deposit|swap)\/?$/) + cy.title().should('match', /Curve - Pool - .* - Curve/) + }) }) - }) - it('paginates', () => { - const getPages = ($buttons: JQuery) => - Cypress.$.makeArray($buttons).map((el) => el.dataset.testid?.replace('btn-page-', '')) + it('paginates', () => { + const getPages = ($buttons: JQuery) => + Cypress.$.makeArray($buttons).map((el) => el.dataset.testid?.replace('btn-page-', '')) - // open page 5 (1-based) - visitAndWait(width, height, { - page: 5, - // show small pools so we have more pages to test with, and the tests are more stable - onBeforeLoad: (win) => setShowSmallPools(win.localStorage), - }) + // open page 5 (1-based) + visitAndWait(width, height, { + page: 5, + // show small pools so we have more pages to test with, and the tests are more stable + onBeforeLoad: (win) => setShowSmallPools(win.localStorage), + }) - // Current page selected - cy.get('[data-testid="btn-page-5"]').should('have.class', 'Mui-selected') - cy.get('[data-testid^="btn-page-"]').then(($buttons) => { - const [prevLastPage, lastPage] = [$buttons.length - 1, $buttons.length] - expect(getPages($buttons)).to.deep.equal([ - 'prev', - '1', - '2', - 'ellipsis', - '4', - '5', - '6', - 'ellipsis', - `${prevLastPage}`, - `${lastPage}`, - 'next', - ]) - - // click on the first page and check again - cy.get('[data-testid="btn-page-1"]').click() - cy.url().should('not.include', `page`) - cy.get('[data-testid^="btn-page-"]').then(($buttons) => - expect(getPages($buttons)).to.deep.equal(['1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`, 'next']), - ) - - // click on the last page and check again - cy.get(`[data-testid="btn-page-${lastPage}"]`).click() - cy.url().should('include', `?page=${lastPage}`) - cy.get('[data-testid^="btn-page-"]').then(($buttons) => - expect(getPages($buttons)).to.deep.equal(['prev', '1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`]), - ) + // Current page selected + cy.get('[data-testid="btn-page-5"]').should('have.class', 'Mui-selected') + cy.get('[data-testid^="btn-page-"]').then(($buttons) => { + const [prevLastPage, lastPage] = [$buttons.length - 1, $buttons.length] + expect(getPages($buttons)).to.deep.equal([ + 'prev', + '1', + '2', + 'ellipsis', + '4', + '5', + '6', + 'ellipsis', + `${prevLastPage}`, + `${lastPage}`, + 'next', + ]) + + // click on the first page and check again + cy.get('[data-testid="btn-page-1"]').click() + cy.url().should('not.include', `page`) + cy.get('[data-testid^="btn-page-"]').then(($buttons) => + expect(getPages($buttons)).to.deep.equal(['1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`, 'next']), + ) + + // click on the last page and check again + cy.get(`[data-testid="btn-page-${lastPage}"]`).click() + cy.url().should('include', `?page=${lastPage}`) + cy.get('[data-testid^="btn-page-"]').then(($buttons) => + expect(getPages($buttons)).to.deep.equal(['prev', '1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`]), + ) + }) }) + + /** + * Clicks on the given filter chip, opening the drawer on mobile if needed. + */ + function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { + if (isMobile) { + cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') + } + cy.get(`[data-testid="filter-chip-${chip}"]`).click() + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') + } + + /** + * Makes sure that the filter chips are visible during the given callback. + * On mobile, the filters are hidden behind a drawer and need to be expanded for some actions. + */ + function withFilterChips(callback: () => Cypress.Chainable, isMobile = breakpoint === 'mobile') { + if (!isMobile) return callback() + cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() + return callback().then((result) => { + cy.get('body').click(0, 0) + return cy.wrap(result) + }) + } }) }) From 7ab1cd933d2bc5cacdac2e1a0bda1e8e3d47f02f Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Nov 2025 15:45:09 +0100 Subject: [PATCH 04/10] chore: self-review --- .../ui/DataTable/hooks/useColumnFilters.tsx | 3 +- tests/cypress/e2e/main/dex-markets.cy.ts | 258 +++++++++--------- 2 files changed, 129 insertions(+), 132 deletions(-) diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx index 76eab5833e..b3601894e6 100644 --- a/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx +++ b/packages/curve-ui-kit/src/shared/ui/DataTable/hooks/useColumnFilters.tsx @@ -8,8 +8,7 @@ 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 staticFilters - Filters that cannot be changed by the user and are not stored in local storage. - * @param title - The title of the table, used as a key for local storage. + * @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. */ diff --git a/tests/cypress/e2e/main/dex-markets.cy.ts b/tests/cypress/e2e/main/dex-markets.cy.ts index a2e9d70a32..432e4db510 100644 --- a/tests/cypress/e2e/main/dex-markets.cy.ts +++ b/tests/cypress/e2e/main/dex-markets.cy.ts @@ -1,7 +1,7 @@ import { orderBy } from 'lodash' import { oneOf } from '@cy/support/generators' import { setShowSmallPools } from '@cy/support/helpers/user-profile' -import { API_LOAD_TIMEOUT, type Breakpoint, LOAD_TIMEOUT, oneMobileViewport } from '@cy/support/ui' +import { API_LOAD_TIMEOUT, type Breakpoint, LOAD_TIMEOUT, oneViewport } from '@cy/support/ui' const PATH = '/dex/arbitrum/pools/' @@ -48,150 +48,148 @@ const getTopUsdValues = (columnId: 'volume' | 'tvl') => ), ) -;[[...oneMobileViewport(), 'mobile'] as const].forEach((viewport) => { - describe('DEX Pools ' + viewport[2], () => { - let breakpoint: Breakpoint - let width: number, height: number +describe('DEX Pools', () => { + let breakpoint: Breakpoint + let width: number, height: number - beforeEach(() => { - ;[width, height, breakpoint] = viewport // oneViewport() - }) + beforeEach(() => { + ;[width, height, breakpoint] = oneViewport() + }) - describe('First page', () => { - beforeEach(() => visitAndWait(width, height)) - - function sortBy(field: string, expectedOrder: 'asc' | 'desc' | false) { - if (breakpoint === 'mobile') { - cy.get('[data-testid="btn-drawer-sort-dex-pools"]').click() - cy.get(`[data-testid="drawer-sort-menu-dex-pools"] li[value="${field}"]`).click() - cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.be.visible') - } else { - cy.get(`[data-testid="data-table-header-${field}"]`).click() - cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.exist') - } - if (expectedOrder) { - cy.get(`[data-testid="icon-sort-${field}-${expectedOrder}"]`).should('be.visible') - } else { - cy.get(`[data-testid^="icon-sort-${field}"]`).should('not.exist') - } + describe('First page', () => { + beforeEach(() => visitAndWait(width, height)) + + function sortBy(field: string, expectedOrder: 'asc' | 'desc' | false) { + if (breakpoint === 'mobile') { + cy.get('[data-testid="btn-drawer-sort-dex-pools"]').click() + cy.get(`[data-testid="drawer-sort-menu-dex-pools"] li[value="${field}"]`).click() + cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.be.visible') + } else { + cy.get(`[data-testid="data-table-header-${field}"]`).click() + cy.get('[data-testid="drawer-sort-menu-dex-pools"]').should('not.exist') } + if (expectedOrder) { + cy.get(`[data-testid="icon-sort-${field}-${expectedOrder}"]`).should('be.visible') + } else { + cy.get(`[data-testid^="icon-sort-${field}"]`).should('not.exist') + } + } - it('sorts by volume', () => { - getTopUsdValues('volume').then((vals) => expectOrder(vals, 'desc')) // initial is Volume desc - cy.url().should('not.include', 'volume') // initial sort not in URL - if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment - sortBy('volume', 'asc') - getTopUsdValues('volume').then((vals) => expectOrder(vals, 'asc')) - cy.url().should('include', 'sort=volume') - }) + it('sorts by volume', () => { + getTopUsdValues('volume').then((vals) => expectOrder(vals, 'desc')) // initial is Volume desc + cy.url().should('not.include', 'volume') // initial sort not in URL + if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment + sortBy('volume', 'asc') + getTopUsdValues('volume').then((vals) => expectOrder(vals, 'asc')) + cy.url().should('include', 'sort=volume') + }) - it('sorts by TVL (desc/asc)', () => { - cy.url().should('not.include', 'tvl') // initial sort not in URL - sortBy('tvl', 'desc') - getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'desc')) - cy.url().should('include', 'sort=-tvl') - if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment - sortBy('tvl', 'asc') - getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'asc')) - cy.url().should('include', 'sort=tvl') - }) + it('sorts by TVL (desc/asc)', () => { + cy.url().should('not.include', 'tvl') // initial sort not in URL + sortBy('tvl', 'desc') + getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'desc')) + cy.url().should('include', 'sort=-tvl') + if (breakpoint === 'mobile') return // on mobile, we cannot sort ascending at the moment + sortBy('tvl', 'asc') + getTopUsdValues('tvl').then((vals) => expectOrder(vals, 'asc')) + cy.url().should('include', 'sort=tvl') + }) - it('filters by currency chip', () => { - const currency = oneOf('usd', 'btc') - const getHiddenCount = () => - withFilterChips(() => cy.get('[data-testid="hidden-market-count"]').then(([{ innerText }]) => innerText)) - - getHiddenCount().then((beforeCount) => { - expect(isNaN(+beforeCount), `Cannot parse hidden count ${beforeCount}`).to.be.false - clickFilterChip(currency) - getHiddenCount().then((afterCount) => { - expect(+afterCount).to.be.greaterThan(+beforeCount) - // chip is in the drawer for mobile, check on desktop that we show count - if (breakpoint !== 'mobile') cy.get(`[data-testid="filter-chip-${currency}"]`).contains(/\(\d+\)/) - }) - cy.get('[data-testid="data-table-cell-PoolName"]').contains(currency.toUpperCase()) + it('filters by currency chip', () => { + const currency = oneOf('usd', 'btc') + const getHiddenCount = () => + withFilterChips(() => cy.get('[data-testid="hidden-market-count"]').then(([{ innerText }]) => innerText)) + + getHiddenCount().then((beforeCount) => { + expect(isNaN(+beforeCount), `Cannot parse hidden count ${beforeCount}`).to.be.false + clickFilterChip(currency) + getHiddenCount().then((afterCount) => { + expect(+afterCount).to.be.greaterThan(+beforeCount) + // chip is in the drawer for mobile, check on desktop that we show count + if (breakpoint !== 'mobile') cy.get(`[data-testid="filter-chip-${currency}"]`).contains(/\(\d+\)/) }) + cy.get('[data-testid="data-table-cell-PoolName"]').contains(currency.toUpperCase()) }) + }) - it('navigates to pool deposit page by clicking a row', () => { - cy.get('[data-testid^="data-table-row-"]').first().click() - if (breakpoint === 'mobile') { - cy.get('[data-testid="collapse-icon"]').first().should('be.visible') - cy.get('[data-testid="pool-link-deposit"]').click() - } - cy.url(LOAD_TIMEOUT).should('match', /\/dex\/arbitrum\/pools\/[^/]+\/(deposit|swap)\/?$/) - cy.title().should('match', /Curve - Pool - .* - Curve/) - }) + it('navigates to pool deposit page by clicking a row', () => { + cy.get('[data-testid^="data-table-row-"]').first().click() + if (breakpoint === 'mobile') { + cy.get('[data-testid="collapse-icon"]').first().should('be.visible') + cy.get('[data-testid="pool-link-deposit"]').click() + } + cy.url(LOAD_TIMEOUT).should('match', /\/dex\/arbitrum\/pools\/[^/]+\/(deposit|swap)\/?$/) + cy.title().should('match', /Curve - Pool - .* - Curve/) }) + }) - it('paginates', () => { - const getPages = ($buttons: JQuery) => - Cypress.$.makeArray($buttons).map((el) => el.dataset.testid?.replace('btn-page-', '')) + it('paginates', () => { + const getPages = ($buttons: JQuery) => + Cypress.$.makeArray($buttons).map((el) => el.dataset.testid?.replace('btn-page-', '')) - // open page 5 (1-based) - visitAndWait(width, height, { - page: 5, - // show small pools so we have more pages to test with, and the tests are more stable - onBeforeLoad: (win) => setShowSmallPools(win.localStorage), - }) - - // Current page selected - cy.get('[data-testid="btn-page-5"]').should('have.class', 'Mui-selected') - cy.get('[data-testid^="btn-page-"]').then(($buttons) => { - const [prevLastPage, lastPage] = [$buttons.length - 1, $buttons.length] - expect(getPages($buttons)).to.deep.equal([ - 'prev', - '1', - '2', - 'ellipsis', - '4', - '5', - '6', - 'ellipsis', - `${prevLastPage}`, - `${lastPage}`, - 'next', - ]) - - // click on the first page and check again - cy.get('[data-testid="btn-page-1"]').click() - cy.url().should('not.include', `page`) - cy.get('[data-testid^="btn-page-"]').then(($buttons) => - expect(getPages($buttons)).to.deep.equal(['1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`, 'next']), - ) - - // click on the last page and check again - cy.get(`[data-testid="btn-page-${lastPage}"]`).click() - cy.url().should('include', `?page=${lastPage}`) - cy.get('[data-testid^="btn-page-"]').then(($buttons) => - expect(getPages($buttons)).to.deep.equal(['prev', '1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`]), - ) - }) + // open page 5 (1-based) + visitAndWait(width, height, { + page: 5, + // show small pools so we have more pages to test with, and the tests are more stable + onBeforeLoad: (win) => setShowSmallPools(win.localStorage), }) - /** - * Clicks on the given filter chip, opening the drawer on mobile if needed. - */ - function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { - if (isMobile) { - cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') - } - cy.get(`[data-testid="filter-chip-${chip}"]`).click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') - } + // Current page selected + cy.get('[data-testid="btn-page-5"]').should('have.class', 'Mui-selected') + cy.get('[data-testid^="btn-page-"]').then(($buttons) => { + const [prevLastPage, lastPage] = [$buttons.length - 1, $buttons.length] + expect(getPages($buttons)).to.deep.equal([ + 'prev', + '1', + '2', + 'ellipsis', + '4', + '5', + '6', + 'ellipsis', + `${prevLastPage}`, + `${lastPage}`, + 'next', + ]) + + // click on the first page and check again + cy.get('[data-testid="btn-page-1"]').click() + cy.url().should('not.include', `page`) + cy.get('[data-testid^="btn-page-"]').then(($buttons) => + expect(getPages($buttons)).to.deep.equal(['1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`, 'next']), + ) + + // click on the last page and check again + cy.get(`[data-testid="btn-page-${lastPage}"]`).click() + cy.url().should('include', `?page=${lastPage}`) + cy.get('[data-testid^="btn-page-"]').then(($buttons) => + expect(getPages($buttons)).to.deep.equal(['prev', '1', '2', 'ellipsis', `${prevLastPage}`, `${lastPage}`]), + ) + }) + }) - /** - * Makes sure that the filter chips are visible during the given callback. - * On mobile, the filters are hidden behind a drawer and need to be expanded for some actions. - */ - function withFilterChips(callback: () => Cypress.Chainable, isMobile = breakpoint === 'mobile') { - if (!isMobile) return callback() + /** + * Clicks on the given filter chip, opening the drawer on mobile if needed. + */ + function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { + if (isMobile) { cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() - return callback().then((result) => { - cy.get('body').click(0, 0) - return cy.wrap(result) - }) + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') } - }) + cy.get(`[data-testid="filter-chip-${chip}"]`).click() + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') + } + + /** + * Makes sure that the filter chips are visible during the given callback. + * On mobile, the filters are hidden behind a drawer and need to be expanded for some actions. + */ + function withFilterChips(callback: () => Cypress.Chainable, isMobile = breakpoint === 'mobile') { + if (!isMobile) return callback() + cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() + return callback().then((result) => { + cy.get('body').click(0, 0) + return cy.wrap(result) + }) + } }) From fd47a2bcd47606b00e328b4bd2b8cff26017e8ba Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Nov 2025 15:59:39 +0100 Subject: [PATCH 05/10] e2e: test small pools filter --- .../user-profile/UserProfileButton.tsx | 2 +- .../settings/HideSmallPoolsSwitch.tsx | 8 +- tests/cypress/e2e/main/dex-markets.cy.ts | 78 ++++++++++++------- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/packages/curve-ui-kit/src/features/user-profile/UserProfileButton.tsx b/packages/curve-ui-kit/src/features/user-profile/UserProfileButton.tsx index 72e3a5a5eb..6d4f72eaa4 100644 --- a/packages/curve-ui-kit/src/features/user-profile/UserProfileButton.tsx +++ b/packages/curve-ui-kit/src/features/user-profile/UserProfileButton.tsx @@ -11,7 +11,7 @@ export const UserProfileButton = ({ visible }: { visible: boolean }) => { return ( <> {visible ? ( - + ) : ( diff --git a/packages/curve-ui-kit/src/features/user-profile/settings/HideSmallPoolsSwitch.tsx b/packages/curve-ui-kit/src/features/user-profile/settings/HideSmallPoolsSwitch.tsx index c980bb9e27..428f59f61b 100644 --- a/packages/curve-ui-kit/src/features/user-profile/settings/HideSmallPoolsSwitch.tsx +++ b/packages/curve-ui-kit/src/features/user-profile/settings/HideSmallPoolsSwitch.tsx @@ -5,6 +5,12 @@ export const HideSmallPoolsSwitch = () => { const hideSmallPools = useUserProfileStore((state) => state.hideSmallPools) const setHideSmallPools = useUserProfileStore((state) => state.setHideSmallPools) return ( - setHideSmallPools(!hideSmallPools)} color="primary" size="small" /> + setHideSmallPools(!hideSmallPools)} + color="primary" + size="small" + data-testid="small-pools-switch" + /> ) } diff --git a/tests/cypress/e2e/main/dex-markets.cy.ts b/tests/cypress/e2e/main/dex-markets.cy.ts index 432e4db510..b5c02cf54a 100644 --- a/tests/cypress/e2e/main/dex-markets.cy.ts +++ b/tests/cypress/e2e/main/dex-markets.cy.ts @@ -2,6 +2,7 @@ import { orderBy } from 'lodash' import { oneOf } from '@cy/support/generators' import { setShowSmallPools } from '@cy/support/helpers/user-profile' import { API_LOAD_TIMEOUT, type Breakpoint, LOAD_TIMEOUT, oneViewport } from '@cy/support/ui' +import { SMALL_POOL_TVL } from '@ui-kit/features/user-profile/store' const PATH = '/dex/arbitrum/pools/' @@ -22,12 +23,16 @@ function parseCompactUsd(value: string): number { return num * 10 ** (unitIndex * 3) } -function visitAndWait(width: number, height: number, options?: { page?: number } & Partial) { +function visitAndWait( + width: number, + height: number, + options?: { query?: Record } & Partial, +) { cy.viewport(width, height) - const { page } = options ?? {} - cy.visit(`${PATH}${page ? `?page=${page}` : ''}`, options) + const { query } = options ?? {} + cy.visit(`${PATH}${query ? `?${new URLSearchParams(query)}` : ''}`, options) cy.get('[data-testid^="data-table-row-"]', API_LOAD_TIMEOUT).should('have.length.greaterThan', 0) - if (page) { + if (query?.['page']) { cy.get('[data-testid="table-pagination"]').should('be.visible') } } @@ -75,6 +80,32 @@ describe('DEX Pools', () => { } } + /** + * Clicks on the given filter chip, opening the drawer on mobile if needed. + * Not using `withFilterChips` because the drawer closes automatically in this case. + */ + function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { + if (isMobile) { + cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') + } + cy.get(`[data-testid="filter-chip-${chip}"]`).click() + cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') + } + + /** + * Makes sure that the filter chips are visible during the given callback. + * On mobile, the filters are hidden behind a drawer and need to be expanded for some actions. + */ + function withFilterChips(callback: () => Cypress.Chainable, isMobile = breakpoint === 'mobile') { + if (!isMobile) return callback() + cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() + return callback().then((result) => { + cy.get('body').click(0, 0) + return cy.wrap(result) + }) + } + it('sorts by volume', () => { getTopUsdValues('volume').then((vals) => expectOrder(vals, 'desc')) // initial is Volume desc cy.url().should('not.include', 'volume') // initial sort not in URL @@ -129,7 +160,7 @@ describe('DEX Pools', () => { // open page 5 (1-based) visitAndWait(width, height, { - page: 5, + query: { page: '5' }, // show small pools so we have more pages to test with, and the tests are more stable onBeforeLoad: (win) => setShowSmallPools(win.localStorage), }) @@ -168,28 +199,19 @@ describe('DEX Pools', () => { }) }) - /** - * Clicks on the given filter chip, opening the drawer on mobile if needed. - */ - function clickFilterChip(chip: string, isMobile = breakpoint === 'mobile') { - if (isMobile) { - cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should('be.visible') - } - cy.get(`[data-testid="filter-chip-${chip}"]`).click() - cy.get('[data-testid="drawer-filter-menu-dex-pools"]').should(isMobile ? 'not.be.visible' : 'not.exist') - } + it('filters small pools', () => { + // by default, small pools are hidden + visitAndWait(width, height, { query: { sort: '-tvl' } }) + getTopUsdValues('tvl').then((vals) => + expect(JSON.stringify(vals.filter((v) => v.parsed < SMALL_POOL_TVL))).to.equal('[]'), + ) - /** - * Makes sure that the filter chips are visible during the given callback. - * On mobile, the filters are hidden behind a drawer and need to be expanded for some actions. - */ - function withFilterChips(callback: () => Cypress.Chainable, isMobile = breakpoint === 'mobile') { - if (!isMobile) return callback() - cy.get('[data-testid="btn-drawer-filter-dex-pools"]').click() - return callback().then((result) => { - cy.get('body').click(0, 0) - return cy.wrap(result) - }) - } + cy.get(`[data-testid='user-profile-button']`).click() + cy.get(`[data-testid='small-pools-switch']`).click() + + cy.get(`[data-testid="data-table-cell-tvl"]`).first().contains('$0') + getTopUsdValues('tvl').then((vals) => + expect(JSON.stringify(vals.filter((v) => v.parsed < SMALL_POOL_TVL))).to.not.equal('[]'), + ) + }) }) From 5f507eaa9a012052b8379acc23eac84ea7c0fb72 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Thu, 6 Nov 2025 12:15:08 +0100 Subject: [PATCH 06/10] fix: tvl filter --- .../dex/features/pool-list/PoolListTable.tsx | 4 +-- packages/curve-ui-kit/src/utils/array.ts | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/main/src/dex/features/pool-list/PoolListTable.tsx b/apps/main/src/dex/features/pool-list/PoolListTable.tsx index b0b49c60a9..8344078bb5 100644 --- a/apps/main/src/dex/features/pool-list/PoolListTable.tsx +++ b/apps/main/src/dex/features/pool-list/PoolListTable.tsx @@ -19,7 +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 { takeTopWithMin } from '@ui-kit/utils' +import { minCutoffForTopK } from '@ui-kit/utils' import { PoolListChips } from './chips/PoolListChips' import { POOL_LIST_COLUMNS, PoolColumnId } from './columns' import { PoolListEmptyState } from './components/PoolListEmptyState' @@ -40,7 +40,7 @@ const useDefaultPoolsFilter = (data: PoolListItem[]) => { hideSmallPools && { id: PoolColumnId.Tvl, value: [ - takeTopWithMin(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), + minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), null, // no upper limit ], }, diff --git a/packages/curve-ui-kit/src/utils/array.ts b/packages/curve-ui-kit/src/utils/array.ts index 033b337934..2d4304b9fc 100644 --- a/packages/curve-ui-kit/src/utils/array.ts +++ b/packages/curve-ui-kit/src/utils/array.ts @@ -21,3 +21,36 @@ export function takeTopWithMin(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( + 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] +} From 8ac44777d6ecb489c4cdaa209109e6acc2fc21c4 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Thu, 6 Nov 2025 13:06:19 +0100 Subject: [PATCH 07/10] fix: `data` type --- .../dex/features/pool-list/PoolListTable.tsx | 17 +++++++++-------- .../pool-list/hooks/usePoolListData.tsx | 6 +++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/main/src/dex/features/pool-list/PoolListTable.tsx b/apps/main/src/dex/features/pool-list/PoolListTable.tsx index 8344078bb5..baf09c7650 100644 --- a/apps/main/src/dex/features/pool-list/PoolListTable.tsx +++ b/apps/main/src/dex/features/pool-list/PoolListTable.tsx @@ -31,19 +31,20 @@ const LOCAL_STORAGE_KEY = 'dex-pool-list' const migration: MigrationOptions = { version: 1 } -const useDefaultPoolsFilter = (data: PoolListItem[]) => { +const useDefaultPoolsFilter = (data: PoolListItem[] | undefined) => { const hideSmallPools = useUserProfileStore((s) => s.hideSmallPools) const { hideSmallPoolsTvl: tvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {} return useMemo( () => notFalsy( - hideSmallPools && { - id: PoolColumnId.Tvl, - value: [ - minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), - null, // no upper limit - ], - }, + data && + hideSmallPools && { + id: PoolColumnId.Tvl, + value: [ + minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), + null, // no upper limit + ], + }, ), [data, tvl, hideSmallPools], ) diff --git a/apps/main/src/dex/features/pool-list/hooks/usePoolListData.tsx b/apps/main/src/dex/features/pool-list/hooks/usePoolListData.tsx index 89d5f48e03..8b54ea0180 100644 --- a/apps/main/src/dex/features/pool-list/hooks/usePoolListData.tsx +++ b/apps/main/src/dex/features/pool-list/hooks/usePoolListData.tsx @@ -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' @@ -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]) @@ -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']) From d24c75354bd1c34c751b17db153f836cf87b219f Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Nov 2025 13:15:33 +0100 Subject: [PATCH 08/10] e2e: handle mobile view --- .../dex/features/pool-list/PoolListTable.tsx | 6 ++--- .../src/widgets/Header/MenuToggleButton.tsx | 8 ++----- tests/cypress/e2e/main/dex-markets.cy.ts | 23 ++++++++++++++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/main/src/dex/features/pool-list/PoolListTable.tsx b/apps/main/src/dex/features/pool-list/PoolListTable.tsx index baf09c7650..1ae4fabfff 100644 --- a/apps/main/src/dex/features/pool-list/PoolListTable.tsx +++ b/apps/main/src/dex/features/pool-list/PoolListTable.tsx @@ -33,7 +33,7 @@ const migration: MigrationOptions = { version: 1 } const useDefaultPoolsFilter = (data: PoolListItem[] | undefined) => { const hideSmallPools = useUserProfileStore((s) => s.hideSmallPools) - const { hideSmallPoolsTvl: tvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {} + const { hideSmallPoolsTvl: minTvl = SMALL_POOL_TVL } = useNetworkFromUrl() ?? {} return useMemo( () => notFalsy( @@ -41,12 +41,12 @@ const useDefaultPoolsFilter = (data: PoolListItem[] | undefined) => { hideSmallPools && { id: PoolColumnId.Tvl, value: [ - minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), tvl, MIN_POOLS_DISPLAYED), + minCutoffForTopK(data, (pool) => +(pool.tvl?.value ?? 0), minTvl, MIN_POOLS_DISPLAYED), null, // no upper limit ], }, ), - [data, tvl, hideSmallPools], + [data, minTvl, hideSmallPools], ) } diff --git a/packages/curve-ui-kit/src/widgets/Header/MenuToggleButton.tsx b/packages/curve-ui-kit/src/widgets/Header/MenuToggleButton.tsx index ac67e9d2ce..4323bd907e 100644 --- a/packages/curve-ui-kit/src/widgets/Header/MenuToggleButton.tsx +++ b/packages/curve-ui-kit/src/widgets/Header/MenuToggleButton.tsx @@ -14,14 +14,10 @@ type MenuToggleButtonProps = { toggle: () => void } +/** Menu toggle button for mobile/tablet view, it animates from a hamburger menu to a cross */ export const MenuToggleButton = ({ toggle, isOpen }: MenuToggleButtonProps) => ( -