Commit 8538b698 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Advanced filter (#1905)

* advanced filter start

* advanced filters stage 2

* adv filters stage 3

* fixes

* add test

* eslint fixes

* age filter update

* some fixes and filters tests

* fixes 2

* add csv export

* fixes 3

* add env variable for adv filter

* fix tests

* edit env temporary
parent 476d7715
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const isDisabled = getEnvValue('NEXT_PUBLIC_ADVANCED_FILTER_ENABLED') === 'false';
const title = 'Advanced filter';
const config: Feature<{}> = (() => {
if (!isDisabled) {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
export { default as advancedFilter } from './advancedFilter';
export { default as account } from './account';
export { default as addressVerification } from './addressVerification';
export { default as addressMetadata } from './addressMetadata';
......
......@@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" }
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com
NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
......
......@@ -844,6 +844,7 @@ const schema = yup
NEXT_PUBLIC_GAS_TRACKER_ENABLED: yup.boolean(),
NEXT_PUBLIC_GAS_TRACKER_UNITS: yup.array().transform(replaceQuotes).json().of(yup.string<GasUnit>().oneOf(GAS_UNITS)),
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: yup.boolean(),
NEXT_PUBLIC_ADVANCED_FILTER_ENABLED: yup.boolean(),
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: yup
.array()
.transform(replaceQuotes)
......
......@@ -29,6 +29,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [App features](ENVS.md#app-features)
- [My account](ENVS.md#my-account)
- [Gas tracker](ENVS.md#gas-tracker)
- [Advanced filter](ENVS.md#advanced-filter)
- [Address verification](ENVS.md#address-verification-in-my-account) in "My account"
- [Blockchain interaction](ENVS.md#blockchain-interaction-writing-to-contract-etc) (writing to contract, etc.)
- [Banner ads](ENVS.md#banner-ads)
......@@ -367,6 +368,16 @@ This feature is **enabled by default**. To switch it off pass `NEXT_PUBLIC_GAS_T
&nbsp;
### Advanced filter
This feature is **enabled by default**. To switch it off pass `NEXT_PUBLIC_ADVANCED_FILTER_ENABLED=false`.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_ADVANCED_FILTER_ENABLED | `boolean` | Set to true to enable "Advanced filter" page in the app | Required | `true` | `false` | v1.37.0+ |
&nbsp;
### Address verification in "My account"
*Note* all ENV variables required for [My account](ENVS.md#my-account) feature should be passed alongside the following ones:
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<g fill="currentColor">
<path d="M1 2.688A1.687 1.687 0 0 1 2.688 1h14.624A1.687 1.687 0 0 1 19 2.688v14.624A1.687 1.687 0 0 1 17.312 19H2.688A1.687 1.687 0 0 1 1 17.312zm1.688-.563a.563.563 0 0 0-.563.563v14.624a.562.562 0 0 0 .563.563h3.937V2.125zm9.562 15.75V2.125h-4.5v15.75zm1.125 0h3.938a.562.562 0 0 0 .562-.563V2.688a.563.563 0 0 0-.563-.562h-3.937z"/>
<path fill-rule="evenodd" d="M2.687 1.15A1.537 1.537 0 0 0 1.15 2.688v14.625a1.538 1.538 0 0 0 1.537 1.537h14.625a1.538 1.538 0 0 0 1.538-1.538V2.688a1.538 1.538 0 0 0-1.538-1.538zm-1.3.238a1.837 1.837 0 0 1 1.3-.538h14.625a1.838 1.838 0 0 1 1.838 1.838v14.624a1.838 1.838 0 0 1-1.838 1.838H2.687A1.837 1.837 0 0 1 .85 17.313V2.688c0-.488.193-.955.538-1.3zm.796.796a.712.712 0 0 1 .504-.209h4.088v16.05H2.687a.713.713 0 0 1-.712-.712V2.688c0-.19.075-.37.208-.504zm.504.091a.413.413 0 0 0-.412.413v14.625a.413.413 0 0 0 .412.412h3.788V2.275zm4.913-.3h4.8v16.05H7.6zm.3.3v15.45h4.2V2.275zm5.325-.3h4.087a.712.712 0 0 1 .713.713v14.625a.712.712 0 0 1-.713.712h-4.087zm.3.3v15.45h3.787a.413.413 0 0 0 .413-.412V2.688a.412.412 0 0 0-.413-.413z" clip-rule="evenodd"/>
</g>
</svg>
......@@ -42,6 +42,7 @@ import type {
} from 'types/api/address';
import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadataSearchFilters } from 'types/api/addresses';
import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata';
import type { AdvancedFilterParams, AdvancedFilterResponse, AdvancedFilterMethodsResponse } from 'types/api/advancedFilter';
import type {
ArbitrumL2MessagesResponse,
ArbitrumL2TxnBatch,
......@@ -1092,6 +1093,41 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ],
},
// ADVANCED FILTER
advanced_filter: {
path: '/api/v2/advanced-filters',
filterFields: [
'tx_types' as const,
'methods' as const,
'methods_names' as const /* frontend only */,
'age_from' as const,
'age_to' as const,
'age' as const /* frontend only */,
'from_address_hashes_to_include' as const,
'from_address_hashes_to_exclude' as const,
'to_address_hashes_to_include' as const,
'to_address_hashes_to_exclude' as const,
'address_relation' as const,
'amount_from' as const,
'amount_to' as const,
'token_contract_address_hashes_to_include' as const,
'token_contract_symbols_to_include' as const /* frontend only */,
'token_contract_address_hashes_to_exclude' as const,
'token_contract_symbols_to_exclude' as const /* frontend only */,
'block_number' as const,
'transaction_index' as const,
'internal_transaction_index' as const,
'token_transfer_index' as const,
],
},
advanced_filter_methods: {
path: '/api/v2/advanced-filters/methods',
filterFields: [ 'q' as const ],
},
advanced_filter_csv: {
path: '/api/v2/advanced-filters/csv',
},
// CONFIGS
config_backend_version: {
path: '/api/v2/config/backend-version',
......@@ -1186,7 +1222,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'noves_address_history' |
'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' |
'scroll_l2_deposits' | 'scroll_l2_withdrawals';
'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
......@@ -1378,6 +1414,8 @@ Q extends 'scroll_l2_deposits' ? ScrollL2MessagesResponse :
Q extends 'scroll_l2_deposits_count' ? number :
Q extends 'scroll_l2_withdrawals' ? ScrollL2MessagesResponse :
Q extends 'scroll_l2_withdrawals_count' ? number :
Q extends 'advanced_filter' ? AdvancedFilterResponse :
Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse :
never;
/* eslint-enable @stylistic/indent */
......@@ -1413,6 +1451,7 @@ Q extends 'validators_stability' ? ValidatorsStabilityFilters :
Q extends 'address_mud_tables' ? AddressMudTablesFilter :
Q extends 'address_mud_records' ? AddressMudRecordsFilter :
Q extends 'token_transfers_all' ? TokenTransferFilters :
Q extends 'advanced_filter' ? AdvancedFilterParams :
never;
/* eslint-enable @stylistic/indent */
......
import getValuesArrayFromQuery from './getValuesArrayFromQuery';
export default function getFilterValue<FilterType>(filterValues: ReadonlyArray<FilterType>, val: string | Array<string> | undefined) {
if (val === undefined) {
return;
}
const valArray = getValuesArrayFromQuery(val);
const valArray = [];
if (typeof val === 'string') {
valArray.push(...val.split(','));
}
if (Array.isArray(val)) {
val.forEach(el => valArray.push(...el.split(',')));
if (!valArray) {
return;
}
return valArray.filter(el => filterValues.includes(el as unknown as FilterType)) as unknown as Array<FilterType>;
......
export default function getValuesArrayFromQuery(val: string | Array<string> | undefined) {
if (val === undefined) {
return;
}
const valArray = [];
if (typeof val === 'string') {
valArray.push(...val.split(','));
}
if (Array.isArray(val)) {
if (!val.length) {
return;
}
val.forEach(el => valArray.push(...el.split(',')));
}
return valArray;
}
......@@ -53,6 +53,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/gas-tracker': 'Root page',
'/mud-worlds': 'Root page',
'/token-transfers': 'Root page',
'/advanced-filter': 'Root page',
// service routes, added only to make typescript happy
'/login': 'Regular page',
......
......@@ -56,6 +56,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/gas-tracker': 'Explore real-time %network_title% gas fees with Blockscout\'s advanced gas fee tracker. Get accurate %network_gwei% estimates and track transaction costs live.',
'/mud-worlds': DEFAULT_TEMPLATE,
'/token-transfers': DEFAULT_TEMPLATE,
'/advanced-filter': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE,
......
......@@ -53,6 +53,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/gas-tracker': 'Track %network_name% gas fees in %network_gwei%',
'/mud-worlds': '%network_name% MUD worlds list',
'/token-transfers': '%network_name% token transfers',
'/advanced-filter': '%network_name% advanced filter',
// service routes, added only to make typescript happy
'/login': '%network_name% login',
......
......@@ -51,6 +51,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/gas-tracker': 'Gas tracker',
'/mud-worlds': 'MUD worlds',
'/token-transfers': 'Token transfers',
'/advanced-filter': 'Advanced filter',
// service routes, added only to make typescript happy
'/login': 'Login',
......
import type { AdvancedFilterResponse } from 'types/api/advancedFilter';
export const baseResponse: AdvancedFilterResponse = {
items: [
{
timestamp: '2024-12-06T12:38:59.000000Z',
total: null,
type: 'coin_transfer',
value: '0',
hash: '0x35e5793d3da98d8e8e3944e40fa15028806502b53a2319501c6acdb8c83ed4bc',
from: {
ens_domain_name: null,
hash: '0xC1b634853Cb333D3aD8663715b08f41A3Aec47cc',
implementations: [],
is_contract: false,
is_verified: false,
metadata: null,
name: null,
private_tags: [],
public_tags: [],
watchlist_names: [],
},
token: null,
to: {
ens_domain_name: null,
hash: '0x1c479675ad559DC151F6Ec7ed3FbF8ceE79582B6',
implementations: [
{
address: '0x31DA64D19Cd31A19CD09F4070366Fe2144792cf7',
name: 'SequencerInbox',
},
],
is_contract: true,
is_verified: true,
metadata: null,
name: 'TransparentUpgradeableProxy',
private_tags: [],
public_tags: [],
watchlist_names: [],
},
method: 'addSequencerL2BatchFromBlobs',
fee: '2657475294553624',
},
{
timestamp: '2024-12-06T12:38:59.000000Z',
total: null,
type: 'coin_transfer',
value: '1328910000000000',
hash: '0x0d7a6c1e91540f767bc4d48bbcf2aa3fa22c93d0d8a60fb34bd7f0ecec5565b0',
from: {
ens_domain_name: null,
hash: '0x9BDc51980d3b81a0fBd031d0F0E39e9E1aFCB294',
implementations: [],
is_contract: false,
is_verified: false,
metadata: null,
name: null,
private_tags: [],
public_tags: [],
watchlist_names: [],
},
token: null,
to: {
ens_domain_name: null,
hash: '0xFe4cda7cc3603bdB9447cAd4A6550290AFeF6b38',
implementations: [],
is_contract: false,
is_verified: false,
metadata: null,
name: null,
private_tags: [],
public_tags: [],
watchlist_names: [],
},
method: null,
fee: '279416150328000',
},
{
timestamp: '2024-12-06T12:38:59.000000Z',
total: null,
type: 'coin_transfer',
value: '0',
hash: '0x925bb2b7bf0b7a37ba4012bd718015cae29fa44e7846a7563c01f11ef99461e2',
from: {
ens_domain_name: null,
hash: '0x807Db16fd01766EE8A7040B6d32F4169c0A0Bf47',
implementations: [],
is_contract: false,
is_verified: false,
metadata: null,
name: null,
private_tags: [],
public_tags: [],
watchlist_names: [],
},
token: null,
to: {
ens_domain_name: null,
hash: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0',
implementations: [],
is_contract: true,
is_verified: true,
metadata: null,
name: 'WstETH',
private_tags: [],
public_tags: [],
watchlist_names: [],
},
method: 'approve',
fee: '620080096879104',
},
],
next_page_params: {
block_number: 5867485,
internal_transaction_index: null,
token_transfer_index: null,
transaction_index: 208,
items_count: 50,
},
search_params: {
tokens: {},
methods: {},
},
};
......@@ -244,6 +244,16 @@ export const gasTracker: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const advancedFilter: GetServerSideProps<Props> = async(context) => {
if (!config.features.advancedFilter.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const dataAvailability: GetServerSideProps<Props> = async(context) => {
if (!config.features.dataAvailability.isEnabled) {
return {
......
......@@ -17,6 +17,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/accounts/label/[slug]", { "slug": string }>
| DynamicRoute<"/address/[hash]/contract-verification", { "hash": string }>
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/advanced-filter">
| StaticRoute<"/api/config">
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/healthz">
......
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import AdvancedFilter from 'ui/pages/AdvancedFilter';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/advanced-filter">
<AdvancedFilter/>
</PageNextJs>
);
};
export default Page;
export { advancedFilter as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -37,6 +37,7 @@
| "clock"
| "coins/bitcoin"
| "collection"
| "columns"
| "contracts/proxy"
| "contracts/regular_many"
| "contracts/regular"
......
import type { AdvancedFilterResponseItem } from 'types/api/advancedFilter';
import { ADDRESS_PARAMS } from './addressParams';
import { TX_HASH } from './tx';
export const ADVANCED_FILTER_ITEM: AdvancedFilterResponseItem = {
fee: '215504444616317',
from: ADDRESS_PARAMS,
hash: TX_HASH,
method: 'approve',
timestamp: '2022-11-11T11:11:11.000000Z',
to: ADDRESS_PARAMS,
token: null,
total: null,
type: 'coin_transfer',
value: '42000420000000000000',
};
......@@ -21,6 +21,7 @@ const variants = {
container: {
bg: mode('gray.100', 'gray.800')(props),
color: mode('gray.500', 'whiteAlpha.800')(props),
cursor: 'pointer',
_hover: {
color: 'blue.400',
opacity: 0.76,
......
import type { AddressParam } from './addressParams';
import type { TokenInfo } from './token';
export type AdvancedFilterParams = {
tx_types?: Array<AdvancedFilterType>;
methods?: Array<string>;
methods_names?: Array<string>; /* frontend only */
age_from?: string;
age_to?: string;
age?: AdvancedFilterAge | ''; /* frontend only */
from_address_hashes_to_include?: Array<string>;
from_address_hashes_to_exclude?: Array<string>;
to_address_hashes_to_include?: Array<string>;
to_address_hashes_to_exclude?: Array<string>;
address_relation?: 'or' | 'and';
amount_from?: string;
amount_to?: string;
token_contract_address_hashes_to_include?: Array<string>;
token_contract_address_hashes_to_exclude?: Array<string>;
token_contract_symbols_to_include?: Array<string>;
token_contract_symbols_to_exclude?: Array<string>;
};
export const ADVANCED_FILTER_TYPES = [ 'coin_transfer', 'ERC-20', 'ERC-404', 'ERC-721', 'ERC-1155' ] as const;
export type AdvancedFilterType = typeof ADVANCED_FILTER_TYPES[number];
export const ADVANCED_FILTER_AGES = [ '1h', '24h', '7d', '1m', '3m', '6m' ] as const;
export type AdvancedFilterAge = typeof ADVANCED_FILTER_AGES[number];
export type AdvancedFilterResponseItem = {
fee: string;
from: AddressParam;
created_contract?: AddressParam;
hash: string;
method: string | null;
timestamp: string;
to: AddressParam;
token: TokenInfo | null;
total: {
decimals: string | null;
value: string;
} | null;
type: string;
value: string | null;
};
export type AdvancedFiltersSearchParams = {
methods: Record<string, string>;
tokens: Record<string, TokenInfo>;
};
export type AdvancedFilterResponse = {
items: Array<AdvancedFilterResponseItem>;
search_params: AdvancedFiltersSearchParams;
next_page_params: {
block_number: number;
internal_transaction_index: number | null;
token_transfer_index: number | null;
transaction_index: number;
items_count: number;
};
};
export type AdvancedFilterMethodsResponse = Array<AdvancedFilterMethodInfo>;
export type AdvancedFilterMethodInfo = {
method_id: string;
name?: string;
};
import React from 'react';
import FilterInput from 'ui/shared/filters/FilterInput';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import TableColumnFilterWrapper from 'ui/shared/filters/TableColumnFilterWrapper';
import AddressMudRecordsKeyFilterContent from './AddressMudRecordsKeyFilterContent';
type Props = {
value?: string;
......@@ -12,29 +13,20 @@ type Props = {
};
const AddressMudRecordsKeyFilter = ({ value = '', handleFilterChange, columnName, title, isLoading }: Props) => {
const [ filterValue, setFilterValue ] = React.useState<string>(value);
const onFilter = React.useCallback(() => {
handleFilterChange(filterValue);
}, [ handleFilterChange, filterValue ]);
return (
<TableColumnFilter
<TableColumnFilterWrapper
columnName={ columnName }
title={ title }
isActive={ Boolean(value) }
isFilled={ filterValue !== value }
onFilter={ onFilter }
isLoading={ isLoading }
w="350px"
>
<FilterInput
initialValue={ value }
size="xs"
onChange={ setFilterValue }
placeholder={ columnName }
<AddressMudRecordsKeyFilterContent
value={ value }
handleFilterChange={ handleFilterChange }
title={ title }
columnName={ columnName }
/>
</TableColumnFilter>
</TableColumnFilterWrapper>
);
};
......
import React from 'react';
import FilterInput from 'ui/shared/filters/FilterInput';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
type Props = {
value?: string;
handleFilterChange: (val: string) => void;
title: string;
columnName: string;
onClose?: () => void;
};
const AddressMudRecordsKeyFilter = ({ value = '', handleFilterChange, columnName, title, onClose }: Props) => {
const [ filterValue, setFilterValue ] = React.useState<string>(value);
const onFilter = React.useCallback(() => {
handleFilterChange(filterValue);
}, [ handleFilterChange, filterValue ]);
return (
<TableColumnFilter
title={ title }
isFilled={ filterValue !== value }
onFilter={ onFilter }
onClose={ onClose }
>
<FilterInput
initialValue={ value }
size="xs"
onChange={ setFilterValue }
placeholder={ columnName }
/>
</TableColumnFilter>
);
};
export default AddressMudRecordsKeyFilter;
import {
chakra,
Flex,
Text,
Link,
Button,
} from '@chakra-ui/react';
import React from 'react';
import ColumnFilterWrapper from './ColumnFilterWrapper';
type Props = {
columnName: string;
title: string;
isActive?: boolean;
isFilled?: boolean;
onFilter: () => void;
onReset?: () => void;
onClose?: () => void;
isLoading?: boolean;
className?: string;
children: React.ReactNode;
};
type ContentProps = {
title: string;
isFilled?: boolean;
onFilter: () => void;
onReset?: () => void;
onClose?: () => void;
children: React.ReactNode;
};
const ColumnFilterContent = ({ title, isFilled, onFilter, onReset, onClose, children }: ContentProps) => {
const onFilterClick = React.useCallback(() => {
onClose && onClose();
onFilter();
}, [ onClose, onFilter ]);
return (
<>
<Flex alignItems="center" justifyContent="space-between" mb={ 3 }>
<Text color="text_secondary" fontWeight="600">{ title }</Text>
<Link
onClick={ onReset }
cursor={ isFilled ? 'pointer' : 'unset' }
opacity={ isFilled ? 1 : 0.2 }
_hover={{
color: isFilled ? 'link_hovered' : 'none',
}}
>
Reset
</Link>
</Flex>
{ children }
<Button
isDisabled={ !isFilled }
mt={ 4 }
onClick={ onFilterClick }
w="fit-content"
>
Filter
</Button>
</>
);
};
const ColumnFilter = ({ columnName, isActive, className, isLoading, ...props }: Props) => {
return (
<ColumnFilterWrapper
isActive={ isActive }
columnName={ columnName }
className={ className }
isLoading={ isLoading }
>
<ColumnFilterContent { ...props }/>
</ColumnFilterWrapper>
);
};
export default chakra(ColumnFilter);
import {
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure,
IconButton,
chakra,
} from '@chakra-ui/react';
import React from 'react';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
columnName: string;
isActive?: boolean;
isLoading?: boolean;
className?: string;
children: React.ReactNode;
}
const ColumnFilterWrapper = ({ columnName, isActive, className, children, isLoading }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const child = React.Children.only(children) as React.ReactElement & {
ref?: React.Ref<React.ReactNode>;
};
const modifiedChildren = React.cloneElement(
child,
{ onClose },
);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy lazyBehavior="unmount">
<PopoverTrigger>
<IconButton
onClick={ onToggle }
aria-label={ `filter by ${ columnName }` }
variant="ghost"
w="30px"
h="30px"
icon={ <IconSvg name="filter" w="20px" h="20px"/> }
isActive={ isActive }
isDisabled={ isLoading }
/>
</PopoverTrigger>
<PopoverContent className={ className }>
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }>
{ modifiedChildren }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default chakra(ColumnFilterWrapper);
import {
Button,
Grid,
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure,
Checkbox,
} from '@chakra-ui/react';
import React from 'react';
import type { ChangeEvent } from 'react';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import { TABLE_COLUMNS } from 'ui/advancedFilter/constants';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
columns: Record<ColumnsIds, boolean>;
onChange: (val: Record<ColumnsIds, boolean>) => void;
}
const ColumnsButton = ({ columns, onChange }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const onCheckboxClick = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const newCols = { ...columns };
const id = event.target.id as ColumnsIds;
newCols[id] = event.target.checked;
onChange(newCols);
}, [ onChange, columns ]);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
onClick={ onToggle }
variant="outline"
colorScheme="gray"
size="sm"
leftIcon={ <IconSvg name="columns" boxSize={ 5 } color="inherit"/> }
>
Columns
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }>
<Grid gridTemplateColumns="160px 160px" gap={ 3 }>
{ TABLE_COLUMNS.map(col => (
<Checkbox
key={ col.id }
defaultChecked={ columns[col.id] }
onChange={ onCheckboxClick }
id={ col.id }
size="lg"
>
{ col.id === 'or_and' ? 'And/Or' : col.name }
</Checkbox>
)) }
</Grid>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ColumnsButton;
import { Button } from '@chakra-ui/react';
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import useToast from 'lib/hooks/useToast';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
type Props = {
filters: AdvancedFilterParams;
};
const ExportCSV = ({ filters }: Props) => {
const recaptcha = useReCaptcha();
const toast = useToast();
const [ isLoading, setIsLoading ] = React.useState(false);
const handleExportCSV = React.useCallback(async() => {
try {
setIsLoading(true);
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl('advanced_filter_csv', undefined, {
...filters,
recaptcha_response: token,
});
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error();
}
const blob = await response.blob();
const fileName = `export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`;
downloadBlob(blob, fileName);
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
} finally {
setIsLoading(false);
}
}, [ toast, filters, recaptcha ]);
if (!config.services.reCaptchaV2.siteKey) {
return null;
}
return (
<>
<Button
onClick={ handleExportCSV }
variant="outline"
isLoading={ isLoading }
size="sm"
mr={ 3 }
>
Export to CSV
</Button>
<ReCaptcha ref={ recaptcha.ref }/>
</>
);
};
export default ExportCSV;
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import { test, expect } from 'playwright/lib';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import FilterByColumn from './FilterByColumn';
const columns: Array<ColumnsIds> = [
'type',
'method',
'age',
'or_and',
'from',
'to',
'amount',
'asset',
];
const filters = {
tx_types: [ 'coin_transfer' as const ],
methods: [ '0xa9059cbb' ],
age: '7d' as const,
address_relation: 'or' as const,
from_address_hashes_to_include: [ '0x123' ],
to_address_hashes_to_include: [ '0x456' ],
amount_from: '100',
token_contract_symbols_to_include: [ 'ETH' ],
token_contract_address_hashes_to_include: [ 'native' ],
};
const searchParams = {
methods: {
'0xa9059cbb': 'transfer',
},
tokens: {},
};
for (const column of columns) {
test(`${ column } filter +@dark-mode`, async({ page, render, mockApiResponse }) => {
await mockApiResponse('tokens', {
items: [],
next_page_params: null,
});
await mockApiResponse('advanced_filter_methods', [], { queryParams: { q: '' } });
await render(
<FilterByColumn
filters={ filters }
searchParams={ searchParams }
column={ column }
columnName="Test"
handleFilterChange={ () => {} }
/>,
);
const filterButton = page.locator('button');
await filterButton.click();
const popover = page.locator('.chakra-popover__content');
await expect(popover).toBeVisible();
await expect(popover).toHaveScreenshot();
});
}
import React from 'react';
import type { AdvancedFilterParams, AdvancedFiltersSearchParams } from 'types/api/advancedFilter';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import TableColumnFilterWrapper from 'ui/shared/filters/TableColumnFilterWrapper';
import { NATIVE_TOKEN } from './constants';
import type { AddressFilterMode } from './filters/AddressFilter';
import AddressFilter from './filters/AddressFilter';
import AddressRelationFilter from './filters/AddressRelationFilter';
import AgeFilter from './filters/AgeFilter';
import AmountFilter from './filters/AmountFilter';
import type { AssetFilterMode } from './filters/AssetFilter';
import AssetFilter from './filters/AssetFilter';
import MethodFilter from './filters/MethodFilter';
import TypeFilter from './filters/TypeFilter';
type Props = {
filters: AdvancedFilterParams;
searchParams?: AdvancedFiltersSearchParams;
column: ColumnsIds;
columnName: string;
handleFilterChange: <T extends keyof AdvancedFilterParams>(field: T, val: AdvancedFilterParams[T]) => void;
isLoading?: boolean;
};
const FilterByColumn = ({ column, filters, columnName, handleFilterChange, searchParams, isLoading }: Props) => {
const commonProps = { columnName, handleFilterChange, isLoading };
switch (column) {
case 'type': {
const value = filters.tx_types;
return (
<TableColumnFilterWrapper
columnName="Type"
isLoading={ isLoading }
isActive={ Boolean(value && value.length) }
>
<TypeFilter { ...commonProps } value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'method': {
const value = filters.methods?.map(m => ({ name: searchParams?.methods[m], method_id: m }));
return (
<TableColumnFilterWrapper
columnName="Method"
isLoading={ isLoading }
isActive={ Boolean(value && value.length) }
w="350px"
>
<MethodFilter { ...commonProps } value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'age': {
const value = { age: filters.age || '' as const, from: filters.age_from || '', to: filters.age_to || '' };
return (
<TableColumnFilterWrapper
columnName="Age"
isLoading={ isLoading }
isActive={ Boolean(value.from || value.to || value.age) }
w="382px"
>
<AgeFilter { ...commonProps } value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'or_and': {
return (
<TableColumnFilterWrapper
columnName="And/Or"
isLoading={ isLoading }
isActive={ false }
w="106px"
value={ filters.address_relation === 'and' ? 'AND' : 'OR' }
>
<AddressRelationFilter { ...commonProps } value={ filters.address_relation }/>
</TableColumnFilterWrapper>
);
}
case 'from': {
const valueInclude = filters?.from_address_hashes_to_include?.map(hash => ({ address: hash, mode: 'include' as AddressFilterMode }));
const valueExclude = filters?.from_address_hashes_to_exclude?.map(hash => ({ address: hash, mode: 'exclude' as AddressFilterMode }));
const value = (valueInclude || []).concat(valueExclude || []);
return (
<TableColumnFilterWrapper
columnName="Address from"
isLoading={ isLoading }
isActive={ Boolean(value.length) }
w="480px"
>
<AddressFilter { ...commonProps } type="from" value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'to': {
const valueInclude = filters?.to_address_hashes_to_include?.map(hash => ({ address: hash, mode: 'include' as AddressFilterMode }));
const valueExclude = filters?.to_address_hashes_to_exclude?.map(hash => ({ address: hash, mode: 'exclude' as AddressFilterMode }));
const value = (valueInclude || []).concat(valueExclude || []);
return (
<TableColumnFilterWrapper
columnName="Address to"
isLoading={ isLoading }
isActive={ Boolean(value.length) }
w="480px"
>
<AddressFilter { ...commonProps } type="to" value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'amount': {
const value = { from: filters.amount_from, to: filters.amount_to };
return (
<TableColumnFilterWrapper
columnName="Amount"
isLoading={ isLoading }
isActive={ Boolean(value.from || value.to) }
w="382px"
>
<AmountFilter { ...commonProps } value={ value }/>
</TableColumnFilterWrapper>
);
}
case 'asset': {
const tokens = searchParams?.tokens;
const value = tokens ?
Object.entries(tokens).map(([ address, token ]) => {
const mode = filters.token_contract_address_hashes_to_include?.find(i => i.toLowerCase() === address.toLowerCase()) ?
'include' as AssetFilterMode :
'exclude' as AssetFilterMode;
return ({ token, mode });
}) : [];
if (filters.token_contract_address_hashes_to_include?.includes('native')) {
value.unshift({ token: NATIVE_TOKEN, mode: 'include' });
}
if (filters.token_contract_address_hashes_to_exclude?.includes('native')) {
value.unshift({ token: NATIVE_TOKEN, mode: 'exclude' });
}
return (
<TableColumnFilterWrapper
columnName="Asset"
isLoading={ isLoading }
isActive={ Boolean(value.length) }
w="382px"
>
<AssetFilter { ...commonProps } value={ value }/>
</TableColumnFilterWrapper>
);
}
default: {
return null;
}
}
};
export default FilterByColumn;
import { Flex, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { AdvancedFilterResponseItem } from 'types/api/advancedFilter';
import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { ADVANCED_FILTER_TYPES } from './constants';
type Props = {
item: AdvancedFilterResponseItem;
column: ColumnsIds;
isLoading?: boolean;
};
const ItemByColumn = ({ item, column, isLoading }: Props) => {
switch (column) {
case 'tx_hash':
return <TxEntity truncation="constant_long" hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 }/>;
case 'type': {
const type = ADVANCED_FILTER_TYPES.find(t => t.id === item.type);
if (!type) {
return null;
}
return <Tag isLoading={ isLoading }>{ type.name }</Tag>;
}
case 'method':
return item.method ? <Tag isLoading={ isLoading } isTruncated colorScheme="gray">{ item.method }</Tag> : null;
case 'age':
return <TimeAgoWithTooltip timestamp={ item.timestamp } isLoading={ isLoading } color="text_secondary" fontWeight={ 400 }/>;
case 'from':
return (
<Flex w="100%">
<AddressEntity address={ item.from } truncation="constant" isLoading={ isLoading }/>
</Flex>
);
case 'to': {
const address = item.to ? item.to : item.created_contract;
if (!address) {
return null;
}
return (
<Flex w="100%">
<AddressEntity address={ address } truncation="constant" isLoading={ isLoading }/>
</Flex>
);
}
case 'or_and':
return (
<AddressFromToIcon
isLoading={ isLoading }
type="unspecified"
/>
);
case 'amount': {
if (item.token?.type === 'ERC-721') {
return <Skeleton isLoaded={ !isLoading }>1</Skeleton>;
}
if (item.total) {
return (
<Skeleton isLoaded={ !isLoading }>
{ getCurrencyValue({ value: item.total?.value, decimals: item.total.decimals, accuracy: 8 }).valueStr }
</Skeleton>
);
}
if (item.value) {
return (
<Skeleton isLoaded={ !isLoading }>
{ getCurrencyValue({ value: item.value, decimals: config.chain.currency.decimals.toString(), accuracy: 8 }).valueStr }
</Skeleton>
);
}
return null;
}
case 'asset':
return item.token ?
<TokenEntity token={ item.token } isLoading={ isLoading } fontWeight={ 700 } onlySymbol noCopy/> :
<Skeleton isLoaded={ !isLoading } fontWeight={ 700 }>{ config.chain.currency.symbol }</Skeleton>;
case 'fee':
return <Skeleton isLoaded={ !isLoading }>{ item.fee ? getCurrencyValue({ value: item.fee, accuracy: 8 }).valueStr : '-' }</Skeleton>;
default:
return null;
}
};
export default ItemByColumn;
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
export type ColumnsIds = 'tx_hash' | 'type' | 'method' | 'age' | 'from' | 'or_and' | 'to' | 'amount' | 'asset' | 'fee';
type TxTableColumn = {
id: ColumnsIds;
name: string;
width: string;
isNumeric?: boolean;
};
export const TABLE_COLUMNS: Array<TxTableColumn> = [
{
id: 'tx_hash',
name: 'Tx hash',
width: '180px',
},
{
id: 'type',
name: 'Type',
width: '160px',
},
{
id: 'method',
name: 'Method',
width: '160px',
},
{
id: 'age',
name: 'Age',
width: '80px',
},
{
id: 'from',
name: 'From',
width: '160px',
},
{
id: 'or_and',
name: '',
width: '60px',
},
{
id: 'to',
name: 'To',
width: '160px',
},
{
id: 'amount',
name: 'Amount',
isNumeric: true,
width: '150px',
},
{
id: 'asset',
name: 'Asset',
width: '120px',
},
{
id: 'fee',
name: 'Fee',
isNumeric: true,
width: '120px',
},
] as const;
export const ADVANCED_FILTER_TYPES = [
{
id: 'coin_transfer',
name: 'Coin Transfer',
},
{
id: 'ERC-20',
name: 'ERC-20',
},
{
id: 'ERC-404',
name: ' ERC-404',
},
{
id: 'ERC-721',
name: 'ERC-721',
},
{
id: 'ERC-1155',
name: 'ERC-1155',
},
] as const;
export const ADVANCED_FILTER_TYPES_WITH_ALL = [
{
id: 'all',
name: 'All',
},
...ADVANCED_FILTER_TYPES,
];
export const NATIVE_TOKEN = {
name: config.chain.currency.name || '',
icon_url: '',
symbol: config.chain.currency.symbol || '',
address: 'native',
type: 'ERC-20' as const,
} as TokenInfo;
import { Flex, Select, Input, InputGroup, InputRightElement, VStack, IconButton } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import ClearButton from 'ui/shared/ClearButton';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import IconSvg from 'ui/shared/IconSvg';
const FILTER_PARAM_TO_INCLUDE = 'to_address_hashes_to_include';
const FILTER_PARAM_FROM_INCLUDE = 'from_address_hashes_to_include';
const FILTER_PARAM_TO_EXCLUDE = 'to_address_hashes_to_exclude';
const FILTER_PARAM_FROM_EXCLUDE = 'from_address_hashes_to_exclude';
export type AddressFilterMode = 'include' | 'exclude';
type Value = Array<{ address: string; mode: AddressFilterMode }>;
type Props = {
value: Value;
handleFilterChange: (filed: keyof AdvancedFilterParams, val: Array<string> | undefined) => void;
columnName: string;
type: 'from' | 'to';
isLoading?: boolean;
onClose?: () => void;
};
type InputProps = {
address?: string;
mode?: AddressFilterMode;
isLast: boolean;
onModeChange: (event: ChangeEvent<HTMLSelectElement>) => void;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onClear: () => void;
onAddFieldClick: () => void;
};
type AddressFilter = {
address: string;
mode: AddressFilterMode;
};
function addressFilterToKey(filter: AddressFilter) {
return `${ filter.address.toLowerCase() }-${ filter.mode }`;
}
const AddressFilterInput = ({ address, mode, onModeChange, onChange, onClear, isLast, onAddFieldClick }: InputProps) => {
return (
<Flex alignItems="center" w="100%">
<Select
size="xs"
borderRadius="base"
value={ mode || 'include' }
onChange={ onModeChange }
minW="105px"
w="105px"
mr={ 3 }
>
<option value="include">Include</option>
<option value="exclude">Exclude</option>
</Select>
<InputGroup size="xs" flexGrow={ 1 }>
<Input value={ address } onChange={ onChange } placeholder="Smart contract / Address (0x...)*" size="xs" autoComplete="off"/>
<InputRightElement>
<ClearButton onClick={ onClear } isDisabled={ !address }/>
</InputRightElement>
</InputGroup>
{ isLast && (
<IconButton
aria-label="add"
variant="outline"
minW="30px"
w="30px"
h="30px"
ml={ 2 }
onClick={ onAddFieldClick }
icon={ <IconSvg name="plus" w="20px" h="20px"/> }
/>
) }
</Flex>
);
};
const emptyItem = { address: '', mode: 'include' as AddressFilterMode };
const AddressFilter = ({ type, value = [], handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] =
React.useState<Array<AddressFilter>>([ ...value, emptyItem ]);
const handleModeSelectChange = React.useCallback((index: number) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value as AddressFilterMode;
setCurrentValue(prev => {
prev[index] = { ...prev[index], mode: value };
return [ ...prev ];
});
}, []);
const handleAddressClear = React.useCallback((index: number) => () => {
setCurrentValue(prev => {
const newVal = [ ...prev ];
newVal[index] = { ...newVal[index], address: '' };
return newVal;
});
}, []);
const handleAddressChange = React.useCallback((index: number) => (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setCurrentValue(prev => {
const newVal = [ ...prev ];
newVal[index] = { ...newVal[index], address: value };
return newVal;
});
}, []);
const onAddFieldClick = React.useCallback(() => {
setCurrentValue(prev => [ ...prev, emptyItem ]);
}, []);
const onReset = React.useCallback(() => setCurrentValue([ emptyItem ]), []);
const onFilter = React.useCallback(() => {
const includeFilterParam = type === 'from' ? FILTER_PARAM_FROM_INCLUDE : FILTER_PARAM_TO_INCLUDE;
const excludeFilterParam = type === 'from' ? FILTER_PARAM_FROM_EXCLUDE : FILTER_PARAM_TO_EXCLUDE;
const includeValue = currentValue.filter(i => i.mode === 'include').map(i => i.address).filter(Boolean);
const excludeValue = currentValue.filter(i => i.mode === 'exclude').map(i => i.address).filter(Boolean);
handleFilterChange(includeFilterParam, includeValue.length ? includeValue : undefined);
handleFilterChange(excludeFilterParam, excludeValue.length ? excludeValue : undefined);
}, [ handleFilterChange, currentValue, type ]);
return (
<TableColumnFilter
title={ type === 'from' ? 'From address' : 'To address' }
isFilled={ Boolean(currentValue[0].address) }
isTouched={ !isEqual(currentValue.filter(i => i.address).map(addressFilterToKey).sort(), value.map(addressFilterToKey).sort()) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<VStack gap={ 2 }>
{ currentValue.map((item, index) => (
<AddressFilterInput
key={ index }
address={ item.address }
mode={ item.mode }
isLast={ index === currentValue.length - 1 }
onModeChange={ handleModeSelectChange(index) }
onChange={ handleAddressChange(index) }
onClear={ handleAddressClear(index) }
onAddFieldClick={ onAddFieldClick }
/>
)) }
</VStack>
</TableColumnFilter>
);
};
export default AddressFilter;
import { Radio, RadioGroup, Stack, Box } from '@chakra-ui/react';
import React from 'react';
import { type AdvancedFilterParams } from 'types/api/advancedFilter';
const FILTER_PARAM = 'address_relation';
type Value = 'or' | 'and';
const DEFAULT_VALUE = 'or' as Value;
type Props = {
value?: Value;
handleFilterChange: (filed: keyof AdvancedFilterParams, value?: string) => void;
columnName: string;
isLoading?: boolean;
onClose?: () => void;
};
const AddressRelationFilter = ({ value = DEFAULT_VALUE, handleFilterChange, onClose }: Props) => {
const onFilter = React.useCallback((val: Value) => {
onClose && onClose();
handleFilterChange(FILTER_PARAM, val);
}, [ handleFilterChange, onClose ]);
return (
<Box w="120px">
<RadioGroup onChange={ onFilter } value={ value }>
<Stack direction="column">
<Radio value="or">OR</Radio>
<Radio value="and">AND</Radio>
</Stack>
</RadioGroup>
</Box>
);
};
export default AddressRelationFilter;
import { Flex, Input, Text } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import type { ChangeEvent } from 'react';
import React from 'react';
import { ADVANCED_FILTER_AGES, type AdvancedFilterAge, type AdvancedFilterParams } from 'types/api/advancedFilter';
import dayjs from 'lib/date/dayjs';
import { ndash } from 'lib/html-entities';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
import { getDurationFromAge } from '../lib';
const FILTER_PARAM_FROM = 'age_from';
const FILTER_PARAM_TO = 'age_to';
const FILTER_PARAM_AGE = 'age';
const defaultValue = { age: '', from: '', to: '' } as const;
type AgeFromToValue = { age: AdvancedFilterAge | ''; from: string; to: string };
type Props = {
value?: AgeFromToValue;
handleFilterChange: (filed: keyof AdvancedFilterParams, value?: string) => void;
columnName: string;
isLoading?: boolean;
onClose?: () => void;
};
const AgeFilter = ({ value = defaultValue, handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<AgeFromToValue>({
age: value.age,
from: value.age ? '' : value.from,
to: value.age ? '' : value.to,
});
const handleFromChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(prev => ({ age: '', to: prev.to, from: event.target.value }));
}, []);
const handleToChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(prev => ({ age: '', from: prev.from, to: event.target.value }));
}, []);
const onPresetChange = React.useCallback((age: AdvancedFilterAge) => {
const from = dayjs((dayjs().valueOf() - getDurationFromAge(age))).toISOString();
handleFilterChange(FILTER_PARAM_FROM, from);
const to = dayjs().toISOString();
handleFilterChange(FILTER_PARAM_TO, to);
handleFilterChange(FILTER_PARAM_AGE, age);
onClose && onClose();
}, [ onClose, handleFilterChange ]);
const onReset = React.useCallback(() => setCurrentValue(defaultValue), []);
const onFilter = React.useCallback(() => {
if (!currentValue.age && !currentValue.to && !currentValue.from) {
handleFilterChange(FILTER_PARAM_FROM, undefined);
handleFilterChange(FILTER_PARAM_TO, undefined);
handleFilterChange(FILTER_PARAM_AGE, undefined);
return;
}
const from = currentValue.age ?
dayjs((dayjs().valueOf() - getDurationFromAge(currentValue.age))).toISOString() :
dayjs(currentValue.from).startOf('day').toISOString();
handleFilterChange(FILTER_PARAM_FROM, from);
const to = currentValue.age ? dayjs().toISOString() : dayjs(currentValue.to).endOf('day').toISOString();
handleFilterChange(FILTER_PARAM_TO, to);
handleFilterChange(FILTER_PARAM_AGE, currentValue.age);
}, [ handleFilterChange, currentValue ]);
return (
<TableColumnFilter
title="Set last duration"
isFilled={ Boolean(currentValue.from || currentValue.to || currentValue.age) }
isTouched={ value.age ? value.age !== currentValue.age : !isEqual(currentValue, value) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<Flex gap={ 3 }>
<TagGroupSelect<AdvancedFilterAge>
items={ ADVANCED_FILTER_AGES.map(val => ({ id: val, title: val })) }
onChange={ onPresetChange }
value={ currentValue.age || undefined }
/>
</Flex>
<Flex mt={ 3 }>
<Input
value={ currentValue.age ? '' : dayjs(currentValue.from).format('YYYY-MM-DD') }
onChange={ handleFromChange }
placeholder="From"
type="date"
size="xs"
/>
<Text mx={ 3 }>{ ndash }</Text>
<Input
value={ currentValue.age ? '' : dayjs(currentValue.to).format('YYYY-MM-DD') }
onChange={ handleToChange }
placeholder="To"
type="date"
size="xs"
/>
</Flex>
</TableColumnFilter>
);
};
export default AgeFilter;
import { Flex, Input, Tag, Text } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import { ndash } from 'lib/html-entities';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
const FILTER_PARAM_FROM = 'amount_from';
const FILTER_PARAM_TO = 'amount_to';
const PRESETS = [
{
value: '10',
name: '<10',
},
{
value: '100',
name: '<100',
},
{
value: '1000',
name: '<1K',
},
{
value: '10000',
name: '<10K',
},
{
value: '100000',
name: '<100K',
},
{
value: '1000000',
name: '<1M',
},
];
const defaultValue = { from: '', to: '' };
type AmountValue = { from?: string; to?: string };
type Props = {
value?: AmountValue;
handleFilterChange: (filed: keyof AdvancedFilterParams, value?: string) => void;
onClose?: () => void;
};
const AmountFilter = ({ value = {}, handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<AmountValue>(value || defaultValue);
const handleFromChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(prev => ({ ...prev, from: event.target.value }));
}, []);
const handleToChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setCurrentValue(prev => ({ ...prev, to: event.target.value }));
}, []);
const onReset = React.useCallback(() => setCurrentValue(defaultValue), []);
const onFilter = React.useCallback(() => {
handleFilterChange(FILTER_PARAM_FROM, currentValue.from);
handleFilterChange(FILTER_PARAM_TO, currentValue.to);
}, [ handleFilterChange, currentValue ]);
const onPresetClick = React.useCallback((event: React.SyntheticEvent) => {
const to = (event.currentTarget as HTMLDivElement).getAttribute('data-id') as string;
handleFilterChange(FILTER_PARAM_FROM, '');
handleFilterChange(FILTER_PARAM_TO, to);
onClose && onClose();
}, [ handleFilterChange, onClose ]);
return (
<TableColumnFilter
title="Amount"
isFilled={ Boolean(currentValue.from || currentValue.to) }
isTouched={ !isEqual(currentValue, value) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<Flex gap={ 3 }>
{ PRESETS.map(preset => (
<Tag
key={ preset.value }
data-id={ preset.value }
onClick={ onPresetClick }
variant="select"
>
{ preset.name }
</Tag>
)) }
</Flex>
<Flex mt={ 3 } alignItems="center">
<Input value={ currentValue.from } onChange={ handleFromChange } placeholder="From" type="number" size="xs"/>
<Text mx={ 3 }>{ ndash }</Text>
<Input value={ currentValue.to } onChange={ handleToChange } placeholder="To" type="number" size="xs"/>
</Flex>
</TableColumnFilter>
);
};
export default AmountFilter;
import { Flex, Checkbox, CheckboxGroup, Text, Spinner, Select } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import type { TokenInfo } from 'types/api/token';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import Tag from 'ui/shared/chakra/Tag';
import ClearButton from 'ui/shared/ClearButton';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import FilterInput from 'ui/shared/filters/FilterInput';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import NativeTokenIcon from 'ui/shared/NativeTokenIcon';
import { NATIVE_TOKEN } from '../constants';
const FILTER_PARAM_INCLUDE = 'token_contract_address_hashes_to_include';
const FILTER_PARAM_EXCLUDE = 'token_contract_address_hashes_to_exclude';
const NAME_PARAM_INCLUDE = 'token_contract_symbols_to_include';
const NAME_PARAM_EXCLUDE = 'token_contract_symbols_to_exclude';
export type AssetFilterMode = 'include' | 'exclude';
// add native token
type Value = Array<{ token: TokenInfo; mode: AssetFilterMode }>;
type Props = {
value: Value;
handleFilterChange: (filed: keyof AdvancedFilterParams, val: Array<string>) => void;
columnName: string;
isLoading?: boolean;
onClose?: () => void;
};
const AssetFilter = ({ value = [], handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<Value>([ ...value ]);
const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const onSearchChange = React.useCallback((value: string) => {
setSearchTerm(value);
}, []);
const handleModeSelectChange = React.useCallback((index: number) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value as AssetFilterMode;
setCurrentValue(prev => {
const newValue = [ ...prev ];
newValue[index] = { ...prev[index], mode: value };
return newValue;
});
}, []);
const handleRemove = React.useCallback((index: number) => () => {
setCurrentValue(prev => {
prev.splice(index, 1);
return [ ...prev ];
});
}, []);
const tokensQuery = useApiQuery('tokens', {
queryParams: { limit: debouncedSearchTerm ? undefined : '7', q: debouncedSearchTerm },
queryOptions: {
refetchOnMount: false,
},
});
const onTokenClick = React.useCallback((token: TokenInfo) => () => {
setCurrentValue(prev => prev.findIndex(i => i.token.address === token.address) > -1 ? prev : [ { token, mode: 'include' }, ...prev ]);
}, []);
const onReset = React.useCallback(() => setCurrentValue([]), []);
const onFilter = React.useCallback(() => {
setSearchTerm('');
handleFilterChange(FILTER_PARAM_INCLUDE, currentValue.filter(i => i.mode === 'include').map(i => i.token.address));
handleFilterChange(NAME_PARAM_INCLUDE, currentValue.filter(i => i.mode === 'include').map(i => i.token.symbol || ''));
handleFilterChange(FILTER_PARAM_EXCLUDE, currentValue.filter(i => i.mode === 'exclude').map(i => i.token.address));
handleFilterChange(NAME_PARAM_EXCLUDE, currentValue.filter(i => i.mode === 'exclude').map(i => i.token.symbol || ''));
return;
}, [ handleFilterChange, currentValue ]);
return (
<TableColumnFilter
title="Asset"
isFilled={ Boolean(currentValue.length) }
isTouched={ !isEqual(currentValue.map(i => JSON.stringify(i)).sort(), value.map(i => JSON.stringify(i)).sort()) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<FilterInput
size="xs"
onChange={ onSearchChange }
placeholder="Token name or symbol"
initialValue={ searchTerm }
/>
{ !searchTerm && currentValue.map((item, index) => (
<Flex key={ item.token.address } alignItems="center">
<Select
size="xs"
borderRadius="base"
value={ item.mode }
onChange={ handleModeSelectChange(index) }
minW="105px"
w="105px"
mr={ 3 }
>
<option value="include">Include</option>
<option value="exclude">Exclude</option>
</Select>
<TokenEntity.default token={ item.token } noLink noCopy flexGrow={ 1 }/>
<ClearButton onClick={ handleRemove(index) }/>
</Flex>
)) }
{ tokensQuery.isLoading && <Spinner display="block" mt={ 3 }/> }
{ tokensQuery.data && !searchTerm && (
<>
<Text color="text_secondary" fontWeight="600" mt={ 3 }>Popular</Text>
<Flex rowGap={ 3 } flexWrap="wrap" gap={ 3 } mb={ 2 }>
{ [ NATIVE_TOKEN, ...tokensQuery.data.items ].map(token => (
<Tag
key={ token.address }
data-id={ token.address }
onClick={ onTokenClick(token) }
variant="select"
>
<Flex flexGrow={ 1 } alignItems="center">
{ token.address === NATIVE_TOKEN.address ? <NativeTokenIcon boxSize={ 5 }/> : <TokenEntity.Icon token={ token }/> }
{ token.symbol || token.name || token.address }
</Flex>
</Tag>
)) }
</Flex>
</>
) }
{ searchTerm && tokensQuery.data && !tokensQuery.data?.items.length && <Text>No tokens found</Text> }
{ searchTerm && tokensQuery.data && Boolean(tokensQuery.data?.items.length) && (
<Flex display="flex" flexDir="column" rowGap={ 3 } maxH="250px" overflowY="scroll" mt={ 3 } ml="-4px">
<CheckboxGroup value={ currentValue.map(i => i.token.address) }>
{ tokensQuery.data.items.map(token => (
<Flex key={ token.address }>
<Checkbox
value={ token.address }
id={ token.address }
onChange={ onTokenClick(token) }
overflow="hidden"
w="100%"
pl={ 1 }
sx={{
'.chakra-checkbox__label': {
flexGrow: 1,
},
}}
>
<TokenEntity.default token={ token } noLink noCopy/>
</Checkbox>
</Flex>
)) }
</CheckboxGroup>
</Flex>
) }
</TableColumnFilter>
);
};
export default AssetFilter;
import { Flex, Checkbox, CheckboxGroup, Spinner, chakra } from '@chakra-ui/react';
import differenceBy from 'lodash/differenceBy';
import isEqual from 'lodash/isEqual';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { AdvancedFilterMethodInfo, AdvancedFilterParams } from 'types/api/advancedFilter';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import Tag from 'ui/shared/chakra/Tag';
import FilterInput from 'ui/shared/filters/FilterInput';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
const RESET_VALUE = 'all';
const FILTER_PARAM = 'methods';
const NAMES_PARAM = 'methods_names';
type Props = {
value?: Array<AdvancedFilterMethodInfo>;
handleFilterChange: (filed: keyof AdvancedFilterParams, val: Array<string>) => void;
onClose?: () => void;
};
const MethodFilter = ({ value = [], handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<Array<AdvancedFilterMethodInfo>>([ ...value ]);
const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const [ methodsList, setMethodsList ] = React.useState<Array<AdvancedFilterMethodInfo>>([]);
const onSearchChange = React.useCallback((value: string) => {
setSearchTerm(value);
}, []);
const methodsQuery = useApiQuery('advanced_filter_methods', {
queryParams: { q: debouncedSearchTerm },
queryOptions: { refetchOnMount: false },
});
React.useEffect(() => {
if (!methodsList.length && methodsQuery.data) {
setMethodsList([ ...value, ...differenceBy(methodsQuery.data, value, i => i.method_id) ]);
}
}, [ methodsQuery.data, value, methodsList ]);
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
const id = event.target.id as string | typeof RESET_VALUE;
if (id === RESET_VALUE) {
setCurrentValue([]);
setMethodsList(methodsQuery.data || []);
} else {
const methodInfo = methodsQuery.data?.find(m => m.method_id === id);
if (methodInfo) {
setCurrentValue(prev => {
return checked ? [ ...prev, methodInfo ] : prev.filter(i => i.method_id !== id);
});
searchTerm && checked &&
setMethodsList(prev => [ methodInfo, ...(prev.filter(m => m.method_id !== id) || []) ]);
}
}
}, [ methodsQuery.data, searchTerm ]);
const onReset = React.useCallback(() => setCurrentValue([]), []);
const onFilter = React.useCallback(() => {
handleFilterChange(FILTER_PARAM, currentValue.map(item => item.method_id));
handleFilterChange(NAMES_PARAM, currentValue.map(item => item.name || ''));
}, [ handleFilterChange, currentValue ]);
return (
<TableColumnFilter
title="Method"
isFilled={ Boolean(currentValue.length) }
isTouched={ !isEqual(currentValue.map(i => JSON.stringify(i)).sort(), value.map(i => JSON.stringify(i)).sort()) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<FilterInput
size="xs"
onChange={ onSearchChange }
placeholder="Find by function name/ method ID"
mb={ 3 }
/>
{ methodsQuery.isLoading && <Spinner/> }
{ methodsQuery.isError && <span>Something went wrong. Please try again.</span> }
{ Boolean(searchTerm) && methodsQuery.data?.length === 0 && <span>No results found.</span> }
{ methodsQuery.data && (
// added negative margin because of checkbox focus styles & overflow hidden
<Flex display="flex" flexDir="column" rowGap={ 3 } maxH="250px" overflowY="scroll" ml="-4px">
<CheckboxGroup value={ currentValue.length ? currentValue.map(i => i.method_id) : [ RESET_VALUE ] }>
{ (searchTerm ? methodsQuery.data : (methodsList || [])).map(method => (
<Checkbox
key={ method.method_id }
value={ method.method_id }
id={ method.method_id }
onChange={ handleChange }
pl={ 1 }
sx={{
'.chakra-checkbox__label': {
flexGrow: 1,
},
}}
>
<Flex justifyContent="space-between" alignItems="center" id={ method.method_id }>
<chakra.span overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ method.name || method.method_id }</chakra.span>
<Tag colorScheme="gray" isTruncated ml={ 2 }>
{ method.method_id }
</Tag>
</Flex>
</Checkbox>
)) }
</CheckboxGroup>
</Flex>
) }
</TableColumnFilter>
);
};
export default MethodFilter;
import { Flex, Checkbox, CheckboxGroup } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import without from 'lodash/without';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { AdvancedFilterParams, AdvancedFilterType } from 'types/api/advancedFilter';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import { ADVANCED_FILTER_TYPES_WITH_ALL } from '../constants';
const RESET_VALUE = 'all';
const FILTER_PARAM = 'tx_types';
type Props = {
value?: Array<AdvancedFilterType>;
handleFilterChange: (filed: keyof AdvancedFilterParams, value: Array<AdvancedFilterType>) => void;
onClose?: () => void;
};
const TypeFilter = ({ value = [], handleFilterChange, onClose }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<Array<AdvancedFilterType>>([ ...value ]);
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
const id = event.target.id as AdvancedFilterType | typeof RESET_VALUE;
if (id === RESET_VALUE) {
setCurrentValue([]);
} else {
setCurrentValue(prev => checked ? [ ...prev, id ] : without(prev, id));
}
}, []);
const onReset = React.useCallback(() => setCurrentValue([]), []);
const onFilter = React.useCallback(() => {
handleFilterChange(FILTER_PARAM, currentValue);
}, [ handleFilterChange, currentValue ]);
return (
<TableColumnFilter
title="Type of transfer"
isFilled={ currentValue.length > 0 }
isTouched={ !isEqual(currentValue.sort(), value.sort()) }
onFilter={ onFilter }
onReset={ onReset }
onClose={ onClose }
hasReset
>
<Flex display="flex" flexDir="column" rowGap={ 3 }>
<CheckboxGroup value={ currentValue.length ? currentValue : [ RESET_VALUE ] }>
{ ADVANCED_FILTER_TYPES_WITH_ALL.map(type => (
<Checkbox
key={ type.id }
value={ type.id }
id={ type.id }
onChange={ handleChange }
>
{ type.name }
</Checkbox>
)) }
</CheckboxGroup>
</Flex>
</TableColumnFilter>
);
};
export default TypeFilter;
import castArray from 'lodash/castArray';
import type { AdvancedFilterAge, AdvancedFilterParams } from 'types/api/advancedFilter';
import { HOUR, DAY, MONTH } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import { ADVANCED_FILTER_TYPES } from './constants';
export function getDurationFromAge(age: AdvancedFilterAge) {
switch (age) {
case '1h':
return HOUR;
case '24h':
return DAY;
case '7d':
return DAY * 7;
case '1m':
return MONTH;
case '3m':
return MONTH * 3;
case '6m':
return MONTH * 6;
}
}
function getFilterValueWithNames(values?: Array<string>, names?: Array<string>) {
if (!names) {
return castArray(values).join(', ');
} else if (Array.isArray(names) && Array.isArray(values)) {
return names.map((n, i) => n ? n : values[i]).join(', ');
} else {
return names;
}
}
const filterParamNames: Record<keyof AdvancedFilterParams, string> = {
// we don't show address_relation as filter tag
address_relation: '',
age: 'Age',
age_from: 'Date from',
age_to: 'Date to',
amount_from: 'Amount from',
amount_to: 'Amount to',
from_address_hashes_to_exclude: 'From Exc',
from_address_hashes_to_include: 'From',
methods: 'Methods',
methods_names: '',
to_address_hashes_to_exclude: 'To Exc',
to_address_hashes_to_include: 'To',
token_contract_address_hashes_to_exclude: 'Asset Exc',
token_contract_symbols_to_exclude: '',
token_contract_address_hashes_to_include: 'Asset',
token_contract_symbols_to_include: '',
tx_types: 'Type',
};
export function getFilterTags(filters: AdvancedFilterParams) {
const filtersToShow = { ...filters };
if (filtersToShow.age) {
filtersToShow.age_from = undefined;
filtersToShow.age_to = undefined;
}
return (Object.entries(filtersToShow) as Array<[keyof AdvancedFilterParams, AdvancedFilterParams[keyof AdvancedFilterParams]]>).map(([ key, value ]) => {
if (!value) {
return;
}
const name = filterParamNames[key as keyof AdvancedFilterParams];
if (!name) {
return;
}
let valueStr;
switch (key) {
case 'methods': {
valueStr = getFilterValueWithNames(filtersToShow.methods, filtersToShow.methods_names);
break;
}
case 'tx_types': {
valueStr = castArray(value).map(i => ADVANCED_FILTER_TYPES.find(t => t.id === i)?.name).filter(Boolean).join(', ');
break;
}
case 'token_contract_address_hashes_to_exclude': {
valueStr = getFilterValueWithNames(filtersToShow.token_contract_address_hashes_to_exclude, filtersToShow.token_contract_symbols_to_exclude);
break;
}
case 'token_contract_address_hashes_to_include': {
valueStr = getFilterValueWithNames(filtersToShow.token_contract_address_hashes_to_include, filtersToShow.token_contract_symbols_to_include);
break;
}
case 'age_from': {
valueStr = dayjs(filtersToShow.age_from).format('YYYY-MM-DD');
break;
}
case 'age_to': {
valueStr = dayjs(filtersToShow.age_to).format('YYYY-MM-DD');
break;
}
default: {
valueStr = castArray(value).join(', ');
}
}
if (!valueStr) {
return;
}
return {
key: key as keyof AdvancedFilterParams,
name,
value: valueStr,
};
}).filter(Boolean);
}
import React from 'react';
import * as advancedFilterMock from 'mocks/advancedFilter/advancedFilter';
import { test, expect } from 'playwright/lib';
import AdvancedFilter from './AdvancedFilter';
test('base view +@dark-mode', async({ render, mockApiResponse, mockTextAd }) => {
await mockTextAd();
await mockApiResponse('advanced_filter', advancedFilterMock.baseResponse);
await mockApiResponse('tokens', { items: [], next_page_params: null }, { queryParams: { limit: '7', q: '' } });
await mockApiResponse('advanced_filter_methods', [], { queryParams: { q: '' } });
const component = await render(<AdvancedFilter/>);
await expect(component).toHaveScreenshot();
});
import {
Table,
Tbody,
Tr,
Th,
Td,
Thead,
Box,
Text,
Tag,
TagCloseButton,
chakra,
Flex,
TagLabel,
HStack,
Link,
} from '@chakra-ui/react';
import omit from 'lodash/omit';
import { useRouter } from 'next/router';
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import { ADVANCED_FILTER_TYPES, ADVANCED_FILTER_AGES } from 'types/api/advancedFilter';
import useApiQuery from 'lib/api/useApiQuery';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import dayjs from 'lib/date/dayjs';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import getValuesArrayFromQuery from 'lib/getValuesArrayFromQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADVANCED_FILTER_ITEM } from 'stubs/advancedFilter';
import { generateListStub } from 'stubs/utils';
import ColumnsButton from 'ui/advancedFilter/ColumnsButton';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import { TABLE_COLUMNS } from 'ui/advancedFilter/constants';
import ExportCSV from 'ui/advancedFilter/ExportCSV';
import FilterByColumn from 'ui/advancedFilter/FilterByColumn';
import ItemByColumn from 'ui/advancedFilter/ItemByColumn';
import { getDurationFromAge, getFilterTags } from 'ui/advancedFilter/lib';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import IconSvg from 'ui/shared/IconSvg';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
const COLUMNS_CHECKED = {} as Record<ColumnsIds, boolean>;
TABLE_COLUMNS.forEach(c => COLUMNS_CHECKED[c.id] = true);
const AdvancedFilter = () => {
const router = useRouter();
const [ filters, setFilters ] = React.useState<AdvancedFilterParams>(() => {
const age = getFilterValueFromQuery(ADVANCED_FILTER_AGES, router.query.age);
return {
tx_types: getFilterValuesFromQuery(ADVANCED_FILTER_TYPES, router.query.tx_types),
methods: getValuesArrayFromQuery(router.query.methods),
methods_names: getValuesArrayFromQuery(router.query.methods_names),
amount_from: getQueryParamString(router.query.amount_from),
amount_to: getQueryParamString(router.query.amount_to),
age,
age_to: age ? dayjs().toISOString() : getQueryParamString(router.query.age_to),
age_from: age ? dayjs((dayjs().valueOf() - getDurationFromAge(age))).toISOString() : getQueryParamString(router.query.age_from),
token_contract_address_hashes_to_exclude: getValuesArrayFromQuery(router.query.token_contract_address_hashes_to_exclude),
token_contract_symbols_to_exclude: getValuesArrayFromQuery(router.query.token_contract_symbols_to_exclude),
token_contract_address_hashes_to_include: getValuesArrayFromQuery(router.query.token_contract_address_hashes_to_include),
token_contract_symbols_to_include: getValuesArrayFromQuery(router.query.token_contract_symbols_to_include),
to_address_hashes_to_include: getValuesArrayFromQuery(router.query.to_address_hashes_to_include),
from_address_hashes_to_include: getValuesArrayFromQuery(router.query.from_address_hashes_to_include),
to_address_hashes_to_exclude: getValuesArrayFromQuery(router.query.to_address_hashes_to_exclude),
from_address_hashes_to_exclude: getValuesArrayFromQuery(router.query.from_address_hashes_to_exclude),
};
});
const [ columns, setColumns ] = React.useState<Record<ColumnsIds, boolean>>(COLUMNS_CHECKED);
const { data, isError, isLoading, pagination, onFilterChange, isPlaceholderData } = useQueryWithPages({
resourceName: 'advanced_filter',
filters,
options: {
placeholderData: generateListStub<'advanced_filter'>(
ADVANCED_FILTER_ITEM,
50,
{
next_page_params: {
block_number: 5867485,
internal_transaction_index: 0,
items_count: 50,
token_transfer_index: null,
transaction_index: 2,
},
search_params: {
tokens: {},
methods: {},
},
},
),
},
});
// maybe don't need to prefetch, but on dev sepolia those requests take several seconds.
useApiQuery('tokens', { queryParams: { limit: '7', q: '' }, queryOptions: { refetchOnMount: false } });
useApiQuery('advanced_filter_methods', { queryParams: { q: '' }, queryOptions: { refetchOnMount: false } });
const handleFilterChange = React.useCallback(<T extends keyof AdvancedFilterParams>(field: T, val: AdvancedFilterParams[T]) => {
setFilters(prevState => {
const newState = { ...prevState };
newState[field] = val;
onFilterChange(newState.age ? omit(newState, [ 'age_from', 'age_to' ]) : newState);
return newState;
});
}, [ onFilterChange ]);
const onClearFilter = React.useCallback((key: keyof AdvancedFilterParams) => () => {
if (key === 'methods') {
handleFilterChange('methods_names', undefined);
}
if (key === 'token_contract_address_hashes_to_exclude') {
handleFilterChange('token_contract_symbols_to_exclude', undefined);
}
if (key === 'token_contract_address_hashes_to_include') {
handleFilterChange('token_contract_symbols_to_include', undefined);
}
if (key === 'age') {
handleFilterChange('age_from', undefined);
handleFilterChange('age_to', undefined);
}
handleFilterChange(key, undefined);
}, [ handleFilterChange ]);
const clearAllFilters = React.useCallback(() => {
setFilters({});
onFilterChange({});
}, [ onFilterChange ]);
const columnsToShow = TABLE_COLUMNS.filter(c => columns[c.id]);
if (isLoading) {
return null;
}
const filterTags = getFilterTags(filters);
const content = (
<AddressHighlightProvider>
<Box maxW="100%" overflowX="scroll" whiteSpace="nowrap">
<Table style={{ tableLayout: 'fixed' }} minWidth="950px" w="100%">
<Thead w="100%" display="table">
<Tr>
{ columnsToShow.map(column => {
return (
<Th
key={ column.id }
isNumeric={ column.isNumeric }
minW={ column.width }
w={ column.width }
wordBreak="break-word"
whiteSpace="normal"
>
{ Boolean(column.name) && <chakra.span mr={ 2 } lineHeight="24px">{ column.name }</chakra.span> }
<FilterByColumn
column={ column.id }
columnName={ column.name }
handleFilterChange={ handleFilterChange }
filters={ filters }
searchParams={ data?.search_params }
isLoading={ isPlaceholderData }
/>
</Th>
);
}) }
</Tr>
</Thead>
<Tbody w="100%" display="table">
{ data?.items.map((item, index) => (
<Tr key={ item.hash + String(index) }>
{ columnsToShow.map(column => (
<Td
key={ item.hash + column.id }
isNumeric={ column.isNumeric }
minW={ column.width }
maxW={ column.width }
w={ column.width }
wordBreak="break-word"
whiteSpace="nowrap"
overflow="hidden"
textAlign={ column.id === 'or_and' ? 'center' : 'start' }
>
<ItemByColumn item={ item } column={ column.id } isLoading={ isPlaceholderData }/>
</Td>
)) }
</Tr>
)) }
</Tbody>
</Table>
</Box>
</AddressHighlightProvider>
);
const actionBar = (
<ActionBar mt={ -6 }>
<ExportCSV filters={ filters }/>
<ColumnsButton columns={ columns } onChange={ setColumns }/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
return (
<>
<PageTitle
title="Advanced filter"
withTextAd
/>
<Flex mb={ 4 } justifyContent="space-between" alignItems="start">
<Text fontSize="lg" mr={ 3 } lineHeight="24px" w="100px">Filtered by:</Text>
{ filterTags.length !== 0 && (
<Link onClick={ clearAllFilters } display="flex" alignItems="center" justifyContent="end" gap={ 2 } fontSize="sm" w="150px">
<IconSvg name="repeat" boxSize={ 5 }/>
Reset filters
</Link>
) }
</Flex>
<HStack gap={ 2 } flexWrap="wrap" mb={ 6 }>
{ filterTags.map(t => (
<Tag key={ t.name } colorScheme="blue" display="inline-flex">
<TagLabel>
<chakra.span color="text_secondary">{ t.name }: </chakra.span>
<chakra.span color="text">{ t.value }</chakra.span>
</TagLabel>
<TagCloseButton onClick={ onClearFilter(t.key) }/>
</Tag>
)) }
{ filterTags.length === 0 && (
<>
<Tag colorScheme="blue" display="inline-flex">
<TagLabel>
<chakra.span color="text_secondary">Type: </chakra.span>
<chakra.span color="text">All</chakra.span>
</TagLabel>
</Tag>
<Tag colorScheme="blue" display="inline-flex">
<TagLabel>
<chakra.span color="text_secondary">Age: </chakra.span>
<chakra.span color="text">7d</chakra.span>
</TagLabel>
</Tag>
</>
) }
</HStack>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no transactions."
content={ content }
actionBar={ actionBar }
filterProps={{
hasActiveFilters: Object.values(filters).some(Boolean),
emptyFilteredText: 'No match found for current filter',
}}
/>
</>
);
};
export default AdvancedFilter;
import { Flex } from '@chakra-ui/react';
import capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -11,6 +12,8 @@ import getNetworkValidationActionText from 'lib/networks/getNetworkValidationAct
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
......@@ -145,6 +148,35 @@ const Transactions = () => {
}
})();
const rightSlot = (() => {
if (isMobile) {
return null;
}
const isAdvancedFilterEnabled = config.features.advancedFilter.isEnabled;
if (!isAdvancedFilterEnabled && !pagination.isVisible) {
return null;
}
return (
<Flex alignItems="center" gap={ 6 }>
{ isAdvancedFilterEnabled && (
<LinkInternal
href="/advanced-filter"
alignItems="center"
display="flex"
gap={ 1 }
>
<IconSvg name="filter" boxSize={ 5 }/>
Advanced filter
</LinkInternal>
) }
{ pagination.isVisible && <Pagination my={ 1 } { ...pagination }/> }
</Flex>
);
})();
return (
<>
<PageTitle
......@@ -155,9 +187,7 @@ const Transactions = () => {
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
rightSlot={ (
pagination.isVisible && !isMobile ? <Pagination my={ 1 } { ...pagination }/> : null
) }
rightSlot={ rightSlot }
stickyEnabled={ !isMobile }
/>
</>
......
......@@ -7,11 +7,10 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import TableColumnFilterWrapper from './TableColumnFilterWrapper';
type ContentProps = {
type Props = {
title: string;
isFilled?: boolean;
isTouched?: boolean;
hasReset?: boolean;
onFilter: () => void;
onReset?: () => void;
......@@ -19,14 +18,7 @@ type ContentProps = {
children: React.ReactNode;
};
type Props = ContentProps & {
columnName: string;
isActive?: boolean;
isLoading?: boolean;
className?: string;
};
const TableColumnFilterContent = ({ title, isFilled, hasReset, onFilter, onReset, onClose, children }: ContentProps) => {
const TableColumnFilter = ({ title, isFilled, isTouched, hasReset, onFilter, onReset, onClose, children }: Props) => {
const onFilterClick = React.useCallback(() => {
onClose && onClose();
onFilter();
......@@ -50,7 +42,7 @@ const TableColumnFilterContent = ({ title, isFilled, hasReset, onFilter, onReset
</Flex>
{ children }
<Button
isDisabled={ !isFilled }
isDisabled={ !isTouched }
onClick={ onFilterClick }
w="fit-content"
>
......@@ -60,17 +52,4 @@ const TableColumnFilterContent = ({ title, isFilled, hasReset, onFilter, onReset
);
};
const TableColumnFilter = ({ columnName, isActive, className, isLoading, ...props }: Props) => {
return (
<TableColumnFilterWrapper
isActive={ isActive }
columnName={ columnName }
className={ className }
isLoading={ isLoading }
>
<TableColumnFilterContent { ...props }/>
</TableColumnFilterWrapper>
);
};
export default chakra(TableColumnFilter);
......@@ -3,9 +3,9 @@ import {
PopoverContent,
PopoverBody,
useDisclosure,
IconButton,
chakra,
Portal,
Button,
} from '@chakra-ui/react';
import React from 'react';
......@@ -18,40 +18,53 @@ interface Props {
isLoading?: boolean;
className?: string;
children: React.ReactNode;
value?: string;
}
const TableColumnFilterWrapper = ({ columnName, isActive, className, children, isLoading }: Props) => {
const TableColumnFilterWrapper = ({ columnName, isActive, className, children, isLoading, value }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const child = React.Children.only(children) as React.ReactElement & {
const content = React.Children.only(children) as React.ReactElement & {
ref?: React.Ref<React.ReactNode>;
};
const modifiedChildren = React.cloneElement(
child,
const modifiedContent = React.cloneElement(
content,
{ onClose },
);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy lazyBehavior="unmount">
<PopoverTrigger>
<IconButton
<Button
onClick={ onToggle }
aria-label={ `filter by ${ columnName }` }
variant="ghost"
w="20px"
h="20px"
icon={ <IconSvg name="filter" w="19px" h="19px"/> }
isActive={ isActive }
isActive={ Boolean(value) || isActive }
isDisabled={ isLoading }
borderRadius="4px"
color="text_secondary"
/>
fontSize="sm"
fontWeight={ 500 }
leftIcon={ <IconSvg name="filter" w="19px" h="19px"/> }
padding={ 0 }
sx={{
'span:only-child': {
mx: 0,
},
'span:not(:only-child)': {
mr: '2px',
},
}}
>
{ Boolean(value) && <chakra.span>{ value }</chakra.span> }
</Button>
</PopoverTrigger>
<Portal>
<PopoverContent className={ className }>
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 3 }>
{ modifiedChildren }
{ modifiedContent }
</PopoverBody>
</PopoverContent>
</Portal>
......
......@@ -7,7 +7,7 @@ type Props<T extends string> = {
tagSize?: TagProps['size'];
} & (
{
value: T;
value?: T;
onChange: (value: T) => void;
isMulti?: false;
} | {
......@@ -44,7 +44,6 @@ const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange, tag
data-id={ item.id }
data-selected={ isSelected }
fontWeight={ 500 }
cursor="pointer"
onClick={ onItemClick }
size={ tagSize }
display="inline-flex"
......
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