Commit d034f644 authored by isstuev's avatar isstuev

tokens sorting

parent 057f4d1f
......@@ -50,7 +50,7 @@ import type {
TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token';
import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction, TransactionsResponseWatchlist } from 'types/api/transaction';
import type { TTxsFilters } from 'types/api/txsFilters';
......@@ -69,6 +69,8 @@ export interface ApiResource {
needAuth?: boolean; // for external APIs which require authentication
}
export const SORTING_FIELDS = [ 'sort', 'order' ];
export const RESOURCES = {
// ACCOUNT
csrf: {
......@@ -633,3 +635,9 @@ Q extends 'tokens' ? TokensFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters :
never;
/* eslint-enable @typescript-eslint/indent */
/* eslint-disable @typescript-eslint/indent */
export type PaginationSorting<Q extends PaginatedResources> =
Q extends 'tokens' ? TokensSorting :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -24,3 +24,8 @@ export interface TokenInstanceTransferPagination {
items_count: number;
token_id: string;
}
export interface TokensSorting {
sort: 'fiat_value' | 'holder_count' | 'circulating_market_cap';
order: 'asc' | 'desc';
}
......@@ -45,6 +45,10 @@ const responses = {
items_count: 43,
},
},
page_sorted: {
items: [ { hash: '61' }, { hash: '62' } ],
next_page_params: null,
},
};
beforeEach(() => {
......@@ -423,13 +427,16 @@ describe('if there is page query param in URL', () => {
});
describe('queries with filters', () => {
it('reset page when filter is changed', async() => {
it('reset page, keep sorting when filter is changed', async() => {
const routerPush = jest.fn(() => Promise.resolve());
useRouter.mockReturnValue({ ...router, pathname: '/current-route', push: routerPush, query: { foo: 'bar' } });
useRouter.mockReturnValue({ ...router, pathname: '/current-route', push: routerPush, query: { foo: 'bar', sort: 'val-desc' } });
const params: Params<'address_txs'> = {
resourceName: 'address_txs',
pathParams: { hash: addressMock.hash },
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
sorting: { sort: 'val-desc' },
};
fetch.once(JSON.stringify(responses.page_1));
fetch.once(JSON.stringify(responses.page_2));
......@@ -462,7 +469,7 @@ describe('queries with filters', () => {
expect(routerPush).toHaveBeenLastCalledWith(
{
pathname: '/current-route',
query: { filter: 'from', foo: 'bar' },
query: { filter: 'from', foo: 'bar', sort: 'val-desc' },
},
undefined,
{ shallow: true },
......@@ -508,6 +515,98 @@ describe('queries with filters', () => {
});
});
describe('queries with sorting', () => {
it('reset page, save filter when sorting is changed', async() => {
const routerPush = jest.fn(() => Promise.resolve());
useRouter.mockReturnValue({ ...router, pathname: '/current-route', push: routerPush, query: { foo: 'bar', filter: 'from' } });
const params: Params<'address_txs'> = {
resourceName: 'address_txs',
pathParams: { hash: addressMock.hash },
filters: { filter: 'from' },
};
fetch.once(JSON.stringify(responses.page_1));
fetch.once(JSON.stringify(responses.page_2));
fetch.once(JSON.stringify(responses.page_sorted));
const { result } = renderHook(() => useQueryWithPages(params), { wrapper });
await waitForApiResponse();
await act(async() => {
result.current.pagination.onNextPageClick();
});
await waitForApiResponse();
await act(async() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
result.current.onSortingChange({ sort: 'val-desc' });
});
await waitForApiResponse();
expect(result.current.data).toEqual(responses.page_sorted);
expect(result.current.pagination).toMatchObject({
page: 1,
canGoBackwards: true,
hasNextPage: false,
isLoading: false,
isVisible: false,
hasPages: false,
});
expect(routerPush).toHaveBeenCalledTimes(2);
expect(routerPush).toHaveBeenLastCalledWith(
{
pathname: '/current-route',
query: { filter: 'from', foo: 'bar', sort: 'val-desc' },
},
undefined,
{ shallow: true },
);
expect(animateScroll.scrollToTop).toHaveBeenCalledTimes(2);
expect(animateScroll.scrollToTop).toHaveBeenLastCalledWith({ duration: 0 });
});
it('saves sorting params in query when navigating between pages', async() => {
const routerPush = jest.fn(() => Promise.resolve());
useRouter.mockReturnValue({ ...router, pathname: '/current-route', push: routerPush, query: { foo: 'bar', sort: 'val-desc' } });
const params: Params<'address_txs'> = {
resourceName: 'address_txs',
pathParams: { hash: addressMock.hash },
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
sorting: { sort: 'val-desc' },
};
fetch.once(JSON.stringify(responses.page_1));
fetch.once(JSON.stringify(responses.page_2));
const { result } = renderHook(() => useQueryWithPages(params), { wrapper });
await waitForApiResponse();
await act(async() => {
result.current.pagination.onNextPageClick();
});
await waitForApiResponse();
expect(routerPush).toHaveBeenCalledTimes(1);
expect(routerPush).toHaveBeenLastCalledWith(
{
pathname: '/current-route',
query: {
sort: 'val-desc',
foo: 'bar',
next_page_params: encodeURIComponent(JSON.stringify(responses.page_1.next_page_params)),
page: '2',
},
},
undefined,
{ shallow: true },
);
});
});
async function waitForApiResponse() {
await flushPromises();
await act(flushPromises);
......
......@@ -7,8 +7,8 @@ import { animateScroll } from 'react-scroll';
import type { PaginationParams } from './types';
import type { PaginatedResources, PaginationFilters, ResourceError, ResourcePayload } from 'lib/api/resources';
import { RESOURCES } from 'lib/api/resources';
import type { PaginatedResources, PaginationFilters, PaginationSorting, ResourceError, ResourcePayload } from 'lib/api/resources';
import { RESOURCES, SORTING_FIELDS } from 'lib/api/resources';
import type { Params as UseApiQueryParams } from 'lib/api/useApiQuery';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -18,6 +18,7 @@ export interface Params<Resource extends PaginatedResources> {
options?: UseApiQueryParams<Resource>['queryOptions'];
pathParams?: UseApiQueryParams<Resource>['pathParams'];
filters?: PaginationFilters<Resource>;
sorting?: PaginationSorting<Resource>;
scrollRef?: React.RefObject<HTMLDivElement>;
}
......@@ -37,12 +38,14 @@ export type QueryWithPagesResult<Resource extends PaginatedResources> =
UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> &
{
onFilterChange: (filters: PaginationFilters<Resource>) => void;
onSortingChange: (sorting?: PaginationSorting<Resource>) => void;
pagination: PaginationParams;
}
export default function useQueryWithPages<Resource extends PaginatedResources>({
resourceName,
filters,
sorting,
options,
pathParams,
scrollRef,
......@@ -59,7 +62,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
const isMounted = React.useRef(false);
const canGoBackwards = React.useRef(!router.query.page);
const queryParams = { ...pageParams[page], ...filters };
const queryParams = { ...pageParams[page], ...filters, ...sorting };
const scrollToTop = useCallback(() => {
scrollRef?.current ? scrollRef.current.scrollIntoView(true) : animateScroll.scrollToTop({ duration: 0 });
......@@ -160,6 +163,26 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
}, [ router, resource.filterFields, scrollToTop ]);
const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => {
const newQuery = {
...omit<typeof router.query>(router.query, 'next_page_params', 'page', SORTING_FIELDS),
...newSorting,
};
scrollToTop();
router.push(
{
pathname: router.pathname,
query: newQuery,
},
undefined,
{ shallow: true },
).then(() => {
setHasPages(false);
setPage(1);
setPageParams({});
});
}, [ router, scrollToTop ]);
const nextPageParams = data?.next_page_params;
const hasNextPage = nextPageParams ? Object.keys(nextPageParams).length > 0 : false;
......@@ -190,5 +213,5 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}, 0);
}, []);
return { ...queryResult, pagination, onFilterChange };
return { ...queryResult, pagination, onFilterChange, onSortingChange };
}
export default function getNextSortValue<SortField extends string, Sort extends string>(
sortSequence: Record<SortField, Array<Sort| undefined>>, field: SortField,
) {
return (prevValue: Sort | undefined) => {
const sequence = sortSequence[field];
const curIndex = sequence.findIndex((sort) => sort === prevValue);
const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1;
return sequence[nextIndex];
};
}
import { Hide, HStack, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import type { Query } from 'nextjs-routes';
import React, { useCallback } from 'react';
import type { TokenType } from 'types/api/token';
import type { TokensSorting } from 'types/api/tokens';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useDebounce from 'lib/hooks/useDebounce';
......@@ -18,6 +20,8 @@ import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import type { Option } from 'ui/shared/sort/Sort';
import Sort from 'ui/shared/sort/Sort';
import TokensListItem from './TokensListItem';
import TokensTable from './TokensTable';
......@@ -25,16 +29,50 @@ import TokensTable from './TokensTable';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
export type TokensSortingField = TokensSorting['sort'];
export type TokensSortingValue = `${ TokensSortingField }-${ TokensSorting['order'] }`;
const SORT_OPTIONS: Array<Option<TokensSortingValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Price ascending', id: 'fiat_value-asc' },
{ title: 'Price descending', id: 'fiat_value-desc' },
{ title: 'Holders ascending', id: 'holder_count-asc' },
{ title: 'Holders descending', id: 'holder_count-desc' },
{ title: 'On-chain market cap ascending', id: 'circulating_market_cap-asc' },
{ title: 'On-chain market cap descending', id: 'circulating_market_cap-desc' },
];
const getSortValueFromQuery = (query: Query): TokensSortingValue | undefined => {
if (!query.sort || !query.order) {
return undefined;
}
const str = query.sort + '-' + query.order;
if (SORT_OPTIONS.map(option => option.id).includes(str)) {
return str as TokensSortingValue;
}
};
const getSortParamsFromValue = (val?: TokensSortingValue): TokensSorting | undefined => {
if (!val) {
return undefined;
}
const sortingChunks = val.split('-') as [ TokensSortingField, TokensSorting['order'] ];
return { sort: sortingChunks[0], order: sortingChunks[1] };
};
const Tokens = () => {
const router = useRouter();
const [ filter, setFilter ] = React.useState<string>(router.query.q?.toString() || '');
const [ sorting, setSorting ] = React.useState<TokensSortingValue | undefined>(getSortValueFromQuery(router.query));
const [ type, setType ] = React.useState<Array<TokenType> | undefined>(getTokenFilterValue(router.query.type));
const debouncedFilter = useDebounce(filter, 300);
const { isError, isPlaceholderData, data, pagination, onFilterChange } = useQueryWithPages({
const { isError, isPlaceholderData, data, pagination, onFilterChange, onSortingChange } = useQueryWithPages({
resourceName: 'tokens',
filters: { q: debouncedFilter, type },
sorting: getSortParamsFromValue(sorting),
options: {
placeholderData: generateListStub<'tokens'>(
TOKEN_INFO_ERC_20,
......@@ -61,6 +99,11 @@ const Tokens = () => {
setType(value);
}, [ debouncedFilter, onFilterChange ]);
const onSort = useCallback((value?: TokensSortingValue) => {
setSorting(value);
onSortingChange(getSortParamsFromValue(value));
}, [ setSorting, onSortingChange ]);
if (isError) {
return <DataFetchAlert/>;
}
......@@ -85,6 +128,11 @@ const Tokens = () => {
<>
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ typeFilter }
<Sort
options={ SORT_OPTIONS }
setSort={ onSort }
sort={ sorting }
/>
{ filterInput }
</HStack>
<ActionBar mt={ -6 }>
......@@ -110,7 +158,16 @@ const Tokens = () => {
/>
)) }
</Show>
<Hide below="lg" ssr={ false }><TokensTable items={ data.items } page={ pagination.page } isLoading={ isPlaceholderData }/></Hide></>
<Hide below="lg" ssr={ false }>
<TokensTable
items={ data.items }
page={ pagination.page }
isLoading={ isPlaceholderData }
setSorting={ onSort }
sorting={ sorting }
/>
</Hide>
</>
) : null;
return (
......
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import { Icon, Link, Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import rightArrowIcon from 'icons/arrows/east.svg';
import { default as getNextSortValueShared } from 'ui/shared/sort/getNextSortValue';
import { default as Thead } from 'ui/shared/TheadSticky';
import type { TokensSortingValue, TokensSortingField } from './Tokens';
import TokensTableItem from './TokensTableItem';
const SORT_SEQUENCE: Record<TokensSortingField, Array<TokensSortingValue | undefined>> = {
fiat_value: [ 'fiat_value-desc', 'fiat_value-asc', undefined ],
holder_count: [ 'holder_count-desc', 'holder_count-asc', undefined ],
circulating_market_cap: [ 'circulating_market_cap-desc', 'circulating_market_cap-asc', undefined ],
};
const getNextSortValue = (getNextSortValueShared<TokensSortingField, TokensSortingValue>).bind(undefined, SORT_SEQUENCE);
type Props = {
items: Array<TokenInfo>;
page: number;
sorting?: TokensSortingValue;
setSorting: (val?: TokensSortingValue) => void;
isLoading?: boolean;
}
const TokensTable = ({ items, page, isLoading }: Props) => {
const TokensTable = ({ items, page, isLoading, sorting, setSorting }: Props) => {
const sortIconTransform = sorting?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)';
const sort = React.useCallback((field: TokensSortingField) => () => {
const value = getNextSortValue(field)(sorting);
setSorting(value);
}, [ sorting, setSorting ]);
return (
<Table>
<Thead top={ 80 }>
<Tr>
<Th w="50%">Token</Th>
<Th isNumeric w="15%">Price</Th>
<Th isNumeric w="20%">On-chain market cap</Th>
<Th isNumeric w="15%">Holders</Th>
<Th isNumeric w="15%">
<Link onClick={ sort('fiat_value') } display="flex" justifyContent="end">
{ sorting?.includes('fiat_value') && <Icon as={ rightArrowIcon } boxSize={ 4 } transform={ sortIconTransform }/> }
Price
</Link>
</Th>
<Th isNumeric w="20%">
<Link onClick={ sort('circulating_market_cap') } display="flex" justifyContent="end">
{ sorting?.includes('circulating_market_cap') && <Icon as={ rightArrowIcon } boxSize={ 4 } transform={ sortIconTransform }/> }
On-chain market cap
</Link>
</Th>
<Th isNumeric w="15%">
<Link onClick={ sort('holder_count') } display="flex" justifyContent="end">
{ sorting?.includes('holder_count') && <Icon as={ rightArrowIcon } boxSize={ 4 } transform={ sortIconTransform }/> }
Holders
</Link>
</Th>
</Tr>
</Thead>
<Tbody>
......
......@@ -13,6 +13,7 @@ import DataListDisplay from 'ui/shared/DataListDisplay';
// import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import { default as getNextSortValueShared } from 'ui/shared/sort/getNextSortValue';
import TxInternalsList from 'ui/tx/internals/TxInternalsList';
import TxInternalsTable from 'ui/tx/internals/TxInternalsTable';
import type { Sort, SortField } from 'ui/tx/internals/utils';
......@@ -25,12 +26,7 @@ const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
'gas-limit': [ 'gas-limit-desc', 'gas-limit-asc', undefined ],
};
const getNextSortValue = (field: SortField) => (prevValue: Sort | undefined) => {
const sequence = SORT_SEQUENCE[field];
const curIndex = sequence.findIndex((sort) => sort === prevValue);
const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1;
return sequence[nextIndex];
};
const getNextSortValue = (getNextSortValueShared<SortField, Sort>).bind(undefined, SORT_SEQUENCE);
const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalTransaction) => {
switch (sort) {
......
import type { VerifiedContract } from 'types/api/contracts';
import compareBns from 'lib/bigint/compareBns';
import { default as getNextSortValueShared } from 'ui/shared/sort/getNextSortValue';
import type { Option } from 'ui/shared/sort/Sort';
export type SortField = 'balance' | 'txs';
......@@ -19,12 +20,7 @@ const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
txs: [ 'txs-desc', 'txs-asc', undefined ],
};
export const getNextSortValue = (field: SortField) => (prevValue: Sort | undefined) => {
const sequence = SORT_SEQUENCE[field];
const curIndex = sequence.findIndex((sort) => sort === prevValue);
const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1;
return sequence[nextIndex];
};
export const getNextSortValue = (getNextSortValueShared<SortField, Sort>).bind(undefined, SORT_SEQUENCE);
export const sortFn = (sort: Sort | undefined) => (a: VerifiedContract, b: VerifiedContract) => {
switch (sort) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment