diff --git a/configs/app/features/advancedFilter.ts b/configs/app/features/advancedFilter.ts new file mode 100644 index 0000000000..7495d369f9 --- /dev/null +++ b/configs/app/features/advancedFilter.ts @@ -0,0 +1,23 @@ +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; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 39b7919614..34e82b8248 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -1,3 +1,4 @@ +export { default as advancedFilter } from './advancedFilter'; export { default as account } from './account'; export { default as addressVerification } from './addressVerification'; export { default as addressMetadata } from './addressMetadata'; diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia index 06c2094b85..a32cf902d5 100644 --- a/configs/envs/.env.eth_sepolia +++ b/configs/envs/.env.eth_sepolia @@ -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 diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 05eb1c52d0..d20fc6d176 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -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().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) diff --git a/docs/ENVS.md b/docs/ENVS.md index 19cb88d94d..b56392ff50 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -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   +### 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+ | + +  + ### 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: diff --git a/icons/columns.svg b/icons/columns.svg new file mode 100644 index 0000000000..d4bfc97368 --- /dev/null +++ b/icons/columns.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 3d1e78ca97..d604537a5c 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -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, @@ -1089,6 +1090,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', @@ -1183,7 +1219,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 = ResourcePayload; @@ -1375,6 +1411,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 */ @@ -1410,6 +1448,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 */ diff --git a/lib/getFilterValuesFromQuery.ts b/lib/getFilterValuesFromQuery.ts index ce5eddeb63..1ba9243d63 100644 --- a/lib/getFilterValuesFromQuery.ts +++ b/lib/getFilterValuesFromQuery.ts @@ -1,14 +1,10 @@ +import getValuesArrayFromQuery from './getValuesArrayFromQuery'; + export default function getFilterValue(filterValues: ReadonlyArray, val: string | Array | 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; diff --git a/lib/getValuesArrayFromQuery.ts b/lib/getValuesArrayFromQuery.ts new file mode 100644 index 0000000000..647eb03cb3 --- /dev/null +++ b/lib/getValuesArrayFromQuery.ts @@ -0,0 +1,18 @@ +export default function getValuesArrayFromQuery(val: string | Array | 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; +} diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index b0c6580b4e..f4c6c9705f 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -53,6 +53,7 @@ const OG_TYPE_DICT: Record = { '/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', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index a05281568c..7e8eb46502 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -56,6 +56,7 @@ const TEMPLATE_MAP: Record = { '/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, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 4629158bc7..5c21d49c9d 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -53,6 +53,7 @@ const TEMPLATE_MAP: Record = { '/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', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 71e52d5b90..6018920c44 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -51,6 +51,7 @@ export const PAGE_TYPE_DICT: Record = { '/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', diff --git a/mocks/advancedFilter/advancedFilter.ts b/mocks/advancedFilter/advancedFilter.ts new file mode 100644 index 0000000000..87f383b0cf --- /dev/null +++ b/mocks/advancedFilter/advancedFilter.ts @@ -0,0 +1,124 @@ +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: {}, + }, +}; diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index aa37379276..9522b73323 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -244,6 +244,16 @@ export const gasTracker: GetServerSideProps = async(context) => { return base(context); }; +export const advancedFilter: GetServerSideProps = async(context) => { + if (!config.features.advancedFilter.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; + export const dataAvailability: GetServerSideProps = async(context) => { if (!config.features.dataAvailability.isEnabled) { return { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index d6a7cb9764..cb870694cb 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -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"> diff --git a/pages/advanced-filter.tsx b/pages/advanced-filter.tsx new file mode 100644 index 0000000000..d0d5c9182a --- /dev/null +++ b/pages/advanced-filter.tsx @@ -0,0 +1,18 @@ +import type { NextPage } from 'next'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +import AdvancedFilter from 'ui/pages/AdvancedFilter'; + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { advancedFilter as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 2beb52be15..1e5264ca10 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -37,6 +37,7 @@ | "clock" | "coins/bitcoin" | "collection" + | "columns" | "contracts/proxy" | "contracts/regular_many" | "contracts/regular" diff --git a/stubs/advancedFilter.ts b/stubs/advancedFilter.ts new file mode 100644 index 0000000000..341280220b --- /dev/null +++ b/stubs/advancedFilter.ts @@ -0,0 +1,17 @@ +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', +}; diff --git a/theme/components/Tag/Tag.ts b/theme/components/Tag/Tag.ts index 70f90a1752..240a2a1c26 100644 --- a/theme/components/Tag/Tag.ts +++ b/theme/components/Tag/Tag.ts @@ -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, diff --git a/types/api/advancedFilter.ts b/types/api/advancedFilter.ts new file mode 100644 index 0000000000..474d299df3 --- /dev/null +++ b/types/api/advancedFilter.ts @@ -0,0 +1,69 @@ +import type { AddressParam } from './addressParams'; +import type { TokenInfo } from './token'; + +export type AdvancedFilterParams = { + tx_types?: Array; + methods?: Array; + methods_names?: Array; /* frontend only */ + age_from?: string; + age_to?: string; + age?: AdvancedFilterAge | ''; /* frontend only */ + from_address_hashes_to_include?: Array; + from_address_hashes_to_exclude?: Array; + to_address_hashes_to_include?: Array; + to_address_hashes_to_exclude?: Array; + address_relation?: 'or' | 'and'; + amount_from?: string; + amount_to?: string; + token_contract_address_hashes_to_include?: Array; + token_contract_address_hashes_to_exclude?: Array; + token_contract_symbols_to_include?: Array; + token_contract_symbols_to_exclude?: Array; +}; + +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; + tokens: Record; +}; + +export type AdvancedFilterResponse = { + items: Array; + 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; + +export type AdvancedFilterMethodInfo = { + method_id: string; + name?: string; +}; diff --git a/ui/address/mud/AddressMudRecordsKeyFilter.tsx b/ui/address/mud/AddressMudRecordsKeyFilter.tsx index 6a42251754..5466d9efe0 100644 --- a/ui/address/mud/AddressMudRecordsKeyFilter.tsx +++ b/ui/address/mud/AddressMudRecordsKeyFilter.tsx @@ -1,7 +1,8 @@ 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(value); - - const onFilter = React.useCallback(() => { - handleFilterChange(filterValue); - }, [ handleFilterChange, filterValue ]); - return ( - - - + ); }; diff --git a/ui/address/mud/AddressMudRecordsKeyFilterContent.tsx b/ui/address/mud/AddressMudRecordsKeyFilterContent.tsx new file mode 100644 index 0000000000..bc7af9dc63 --- /dev/null +++ b/ui/address/mud/AddressMudRecordsKeyFilterContent.tsx @@ -0,0 +1,38 @@ +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(value); + + const onFilter = React.useCallback(() => { + handleFilterChange(filterValue); + }, [ handleFilterChange, filterValue ]); + + return ( + + + + ); +}; + +export default AddressMudRecordsKeyFilter; diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png index 3277c7d982..957801fc71 100644 Binary files a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png index 743e54e78b..2500a668a3 100644 Binary files a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_default_expanded-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png index bc064b1ea6..c53de56964 100644 Binary files a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png index 599c4764e2..1b8a8c500a 100644 Binary files a/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png and b/ui/address/mud/__screenshots__/AddressMudTable.pw.tsx_mobile_expanded-view-mobile-1.png differ diff --git a/ui/advancedFilter/ColumnFilter.tsx b/ui/advancedFilter/ColumnFilter.tsx new file mode 100644 index 0000000000..bd1d91a961 --- /dev/null +++ b/ui/advancedFilter/ColumnFilter.tsx @@ -0,0 +1,80 @@ +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 ( + <> + + { title } + + Reset + + + { children } + + + ); +}; + +const ColumnFilter = ({ columnName, isActive, className, isLoading, ...props }: Props) => { + return ( + + + + ); +}; + +export default chakra(ColumnFilter); diff --git a/ui/advancedFilter/ColumnFilterWrapper.tsx b/ui/advancedFilter/ColumnFilterWrapper.tsx new file mode 100644 index 0000000000..184ff04def --- /dev/null +++ b/ui/advancedFilter/ColumnFilterWrapper.tsx @@ -0,0 +1,57 @@ +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; + }; + + const modifiedChildren = React.cloneElement( + child, + { onClose }, + ); + + return ( + + + } + isActive={ isActive } + isDisabled={ isLoading } + /> + + + + { modifiedChildren } + + + + ); +}; + +export default chakra(ColumnFilterWrapper); diff --git a/ui/advancedFilter/ColumnsButton.tsx b/ui/advancedFilter/ColumnsButton.tsx new file mode 100644 index 0000000000..77659617a5 --- /dev/null +++ b/ui/advancedFilter/ColumnsButton.tsx @@ -0,0 +1,67 @@ +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; + onChange: (val: Record) => void; +} + +const ColumnsButton = ({ columns, onChange }: Props) => { + const { isOpen, onToggle, onClose } = useDisclosure(); + + const onCheckboxClick = React.useCallback((event: ChangeEvent) => { + const newCols = { ...columns }; + const id = event.target.id as ColumnsIds; + newCols[id] = event.target.checked; + onChange(newCols); + }, [ onChange, columns ]); + + return ( + + + + + + + + { TABLE_COLUMNS.map(col => ( + + { col.id === 'or_and' ? 'And/Or' : col.name } + + )) } + + + + + ); +}; + +export default ColumnsButton; diff --git a/ui/advancedFilter/ExportCSV.tsx b/ui/advancedFilter/ExportCSV.tsx new file mode 100644 index 0000000000..cade60a19c --- /dev/null +++ b/ui/advancedFilter/ExportCSV.tsx @@ -0,0 +1,85 @@ +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 ( + <> + + + + ); +}; + +export default ExportCSV; diff --git a/ui/advancedFilter/FilterByColumn.pw.tsx b/ui/advancedFilter/FilterByColumn.pw.tsx new file mode 100644 index 0000000000..861119ae1e --- /dev/null +++ b/ui/advancedFilter/FilterByColumn.pw.tsx @@ -0,0 +1,62 @@ +/* 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 = [ + '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( + {} } + />, + ); + + const filterButton = page.locator('button'); + await filterButton.click(); + const popover = page.locator('.chakra-popover__content'); + await expect(popover).toBeVisible(); + await expect(popover).toHaveScreenshot(); + }); +} diff --git a/ui/advancedFilter/FilterByColumn.tsx b/ui/advancedFilter/FilterByColumn.tsx new file mode 100644 index 0000000000..3c997c1c4a --- /dev/null +++ b/ui/advancedFilter/FilterByColumn.tsx @@ -0,0 +1,161 @@ +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: (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 ( + + + + ); + } + case 'method': { + const value = filters.methods?.map(m => ({ name: searchParams?.methods[m], method_id: m })); + return ( + + + + ); + } + case 'age': { + const value = { age: filters.age || '' as const, from: filters.age_from || '', to: filters.age_to || '' }; + return ( + + + + ); + } + case 'or_and': { + return ( + + + + ); + } + 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 ( + + + + ); + + } + 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 ( + + + + ); + } + case 'amount': { + const value = { from: filters.amount_from, to: filters.amount_to }; + return ( + + + + ); + } + 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 ( + + + + ); + } + default: { + return null; + } + } +}; + +export default FilterByColumn; diff --git a/ui/advancedFilter/ItemByColumn.tsx b/ui/advancedFilter/ItemByColumn.tsx new file mode 100644 index 0000000000..c9906d158d --- /dev/null +++ b/ui/advancedFilter/ItemByColumn.tsx @@ -0,0 +1,94 @@ +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 ; + case 'type': { + const type = ADVANCED_FILTER_TYPES.find(t => t.id === item.type); + if (!type) { + return null; + } + return { type.name }; + } + case 'method': + return item.method ? { item.method } : null; + case 'age': + return ; + case 'from': + return ( + + + + ); + case 'to': { + const address = item.to ? item.to : item.created_contract; + if (!address) { + return null; + } + return ( + + + + ); + } + case 'or_and': + return ( + + ); + case 'amount': { + if (item.token?.type === 'ERC-721') { + return 1; + } + if (item.total) { + return ( + + { getCurrencyValue({ value: item.total?.value, decimals: item.total.decimals, accuracy: 8 }).valueStr } + + ); + } + if (item.value) { + return ( + + { getCurrencyValue({ value: item.value, decimals: config.chain.currency.decimals.toString(), accuracy: 8 }).valueStr } + + ); + } + return null; + } + case 'asset': + return item.token ? + : + { config.chain.currency.symbol }; + case 'fee': + return { item.fee ? getCurrencyValue({ value: item.fee, accuracy: 8 }).valueStr : '-' }; + default: + return null; + } +}; + +export default ItemByColumn; diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png new file mode 100644 index 0000000000..164459af60 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png new file mode 100644 index 0000000000..7429a3ea3b Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png new file mode 100644 index 0000000000..c5fcb0b4b4 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png new file mode 100644 index 0000000000..e93ea7235d Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png new file mode 100644 index 0000000000..58a5e8cdb3 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_or-and-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_or-and-filter-dark-mode-1.png new file mode 100644 index 0000000000..965fcbed77 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_or-and-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png new file mode 100644 index 0000000000..4af8428bf3 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_type-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_type-filter-dark-mode-1.png new file mode 100644 index 0000000000..2a3479a707 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_type-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png new file mode 100644 index 0000000000..badc02e3f3 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png new file mode 100644 index 0000000000..40fb268055 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png new file mode 100644 index 0000000000..62ff47d6c3 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png new file mode 100644 index 0000000000..7cadcd7861 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png new file mode 100644 index 0000000000..cb767076df Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_or-and-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_or-and-filter-dark-mode-1.png new file mode 100644 index 0000000000..115a9da519 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_or-and-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png new file mode 100644 index 0000000000..247e4a4b81 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_type-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_type-filter-dark-mode-1.png new file mode 100644 index 0000000000..fe404f96a7 Binary files /dev/null and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_type-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/constants.ts b/ui/advancedFilter/constants.ts new file mode 100644 index 0000000000..7da669724b --- /dev/null +++ b/ui/advancedFilter/constants.ts @@ -0,0 +1,106 @@ +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 = [ + { + 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; diff --git a/ui/advancedFilter/filters/AddressFilter.tsx b/ui/advancedFilter/filters/AddressFilter.tsx new file mode 100644 index 0000000000..9bb197a099 --- /dev/null +++ b/ui/advancedFilter/filters/AddressFilter.tsx @@ -0,0 +1,162 @@ +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 | undefined) => void; + columnName: string; + type: 'from' | 'to'; + isLoading?: boolean; + onClose?: () => void; +}; + +type InputProps = { + address?: string; + mode?: AddressFilterMode; + isLast: boolean; + onModeChange: (event: ChangeEvent) => void; + onChange: (event: ChangeEvent) => 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 ( + + + + + + + + + { isLast && ( + } + /> + ) } + + ); +}; + +const emptyItem = { address: '', mode: 'include' as AddressFilterMode }; + +const AddressFilter = ({ type, value = [], handleFilterChange, onClose }: Props) => { + const [ currentValue, setCurrentValue ] = + React.useState>([ ...value, emptyItem ]); + + const handleModeSelectChange = React.useCallback((index: number) => (event: React.ChangeEvent) => { + 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) => { + 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 ( + i.address).map(addressFilterToKey).sort(), value.map(addressFilterToKey).sort()) } + onFilter={ onFilter } + onReset={ onReset } + onClose={ onClose } + hasReset + > + + { currentValue.map((item, index) => ( + + )) } + + + ); +}; + +export default AddressFilter; diff --git a/ui/advancedFilter/filters/AddressRelationFilter.tsx b/ui/advancedFilter/filters/AddressRelationFilter.tsx new file mode 100644 index 0000000000..725e6cfd40 --- /dev/null +++ b/ui/advancedFilter/filters/AddressRelationFilter.tsx @@ -0,0 +1,38 @@ +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 ( + + + + OR + AND + + + + ); +}; + +export default AddressRelationFilter; diff --git a/ui/advancedFilter/filters/AgeFilter.tsx b/ui/advancedFilter/filters/AgeFilter.tsx new file mode 100644 index 0000000000..370407e518 --- /dev/null +++ b/ui/advancedFilter/filters/AgeFilter.tsx @@ -0,0 +1,110 @@ +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({ + age: value.age, + from: value.age ? '' : value.from, + to: value.age ? '' : value.to, + }); + + const handleFromChange = React.useCallback((event: ChangeEvent) => { + setCurrentValue(prev => ({ age: '', to: prev.to, from: event.target.value })); + }, []); + + const handleToChange = React.useCallback((event: ChangeEvent) => { + 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 ( + + + + items={ ADVANCED_FILTER_AGES.map(val => ({ id: val, title: val })) } + onChange={ onPresetChange } + value={ currentValue.age || undefined } + /> + + + + { ndash } + + + + ); +}; + +export default AgeFilter; diff --git a/ui/advancedFilter/filters/AmountFilter.tsx b/ui/advancedFilter/filters/AmountFilter.tsx new file mode 100644 index 0000000000..df64eb08e6 --- /dev/null +++ b/ui/advancedFilter/filters/AmountFilter.tsx @@ -0,0 +1,106 @@ +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(value || defaultValue); + + const handleFromChange = React.useCallback((event: ChangeEvent) => { + setCurrentValue(prev => ({ ...prev, from: event.target.value })); + }, []); + + const handleToChange = React.useCallback((event: ChangeEvent) => { + 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 ( + + + { PRESETS.map(preset => ( + + { preset.name } + + )) } + + + + { ndash } + + + + ); +}; + +export default AmountFilter; diff --git a/ui/advancedFilter/filters/AssetFilter.tsx b/ui/advancedFilter/filters/AssetFilter.tsx new file mode 100644 index 0000000000..048e8aeddb --- /dev/null +++ b/ui/advancedFilter/filters/AssetFilter.tsx @@ -0,0 +1,169 @@ +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) => void; + columnName: string; + isLoading?: boolean; + onClose?: () => void; +}; + +const AssetFilter = ({ value = [], handleFilterChange, onClose }: Props) => { + const [ currentValue, setCurrentValue ] = React.useState([ ...value ]); + const [ searchTerm, setSearchTerm ] = React.useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const onSearchChange = React.useCallback((value: string) => { + setSearchTerm(value); + }, []); + + const handleModeSelectChange = React.useCallback((index: number) => (event: React.ChangeEvent) => { + 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 ( + JSON.stringify(i)).sort(), value.map(i => JSON.stringify(i)).sort()) } + onFilter={ onFilter } + onReset={ onReset } + onClose={ onClose } + hasReset + > + + { !searchTerm && currentValue.map((item, index) => ( + + + + + + )) } + { tokensQuery.isLoading && } + { tokensQuery.data && !searchTerm && ( + <> + Popular + + { [ NATIVE_TOKEN, ...tokensQuery.data.items ].map(token => ( + + + { token.address === NATIVE_TOKEN.address ? : } + { token.symbol || token.name || token.address } + + + )) } + + + ) } + { searchTerm && tokensQuery.data && !tokensQuery.data?.items.length && No tokens found } + { searchTerm && tokensQuery.data && Boolean(tokensQuery.data?.items.length) && ( + + i.token.address) }> + { tokensQuery.data.items.map(token => ( + + + + + + )) } + + + ) } + + ); +}; + +export default AssetFilter; diff --git a/ui/advancedFilter/filters/MethodFilter.tsx b/ui/advancedFilter/filters/MethodFilter.tsx new file mode 100644 index 0000000000..93bc65e45d --- /dev/null +++ b/ui/advancedFilter/filters/MethodFilter.tsx @@ -0,0 +1,123 @@ +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; + handleFilterChange: (filed: keyof AdvancedFilterParams, val: Array) => void; + onClose?: () => void; +}; + +const MethodFilter = ({ value = [], handleFilterChange, onClose }: Props) => { + const [ currentValue, setCurrentValue ] = React.useState>([ ...value ]); + const [ searchTerm, setSearchTerm ] = React.useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + const [ methodsList, setMethodsList ] = React.useState>([]); + + 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) => { + 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 ( + JSON.stringify(i)).sort(), value.map(i => JSON.stringify(i)).sort()) } + onFilter={ onFilter } + onReset={ onReset } + onClose={ onClose } + hasReset + > + + { methodsQuery.isLoading && } + { methodsQuery.isError && Something went wrong. Please try again. } + { Boolean(searchTerm) && methodsQuery.data?.length === 0 && No results found. } + { methodsQuery.data && ( + // added negative margin because of checkbox focus styles & overflow hidden + + i.method_id) : [ RESET_VALUE ] }> + { (searchTerm ? methodsQuery.data : (methodsList || [])).map(method => ( + + + { method.name || method.method_id } + + { method.method_id } + + + + )) } + + + ) } + + ); +}; + +export default MethodFilter; diff --git a/ui/advancedFilter/filters/TypeFilter.tsx b/ui/advancedFilter/filters/TypeFilter.tsx new file mode 100644 index 0000000000..f3a61c2562 --- /dev/null +++ b/ui/advancedFilter/filters/TypeFilter.tsx @@ -0,0 +1,70 @@ +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; + handleFilterChange: (filed: keyof AdvancedFilterParams, value: Array) => void; + onClose?: () => void; +}; + +const TypeFilter = ({ value = [], handleFilterChange, onClose }: Props) => { + const [ currentValue, setCurrentValue ] = React.useState>([ ...value ]); + + const handleChange = React.useCallback((event: ChangeEvent) => { + 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 ( + 0 } + isTouched={ !isEqual(currentValue.sort(), value.sort()) } + onFilter={ onFilter } + onReset={ onReset } + onClose={ onClose } + hasReset + > + + + { ADVANCED_FILTER_TYPES_WITH_ALL.map(type => ( + + { type.name } + + )) } + + + + ); +}; + +export default TypeFilter; diff --git a/ui/advancedFilter/lib.ts b/ui/advancedFilter/lib.ts new file mode 100644 index 0000000000..3b8380fde2 --- /dev/null +++ b/ui/advancedFilter/lib.ts @@ -0,0 +1,113 @@ +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, names?: Array) { + 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 = { + // 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); +} diff --git a/ui/pages/AdvancedFilter.pw.tsx b/ui/pages/AdvancedFilter.pw.tsx new file mode 100644 index 0000000000..406e970ed1 --- /dev/null +++ b/ui/pages/AdvancedFilter.pw.tsx @@ -0,0 +1,17 @@ +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(); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/AdvancedFilter.tsx b/ui/pages/AdvancedFilter.tsx new file mode 100644 index 0000000000..9fc6a5a9f1 --- /dev/null +++ b/ui/pages/AdvancedFilter.tsx @@ -0,0 +1,266 @@ +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; +TABLE_COLUMNS.forEach(c => COLUMNS_CHECKED[c.id] = true); + +const AdvancedFilter = () => { + const router = useRouter(); + + const [ filters, setFilters ] = React.useState(() => { + 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>(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((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 = ( + + + + + + { columnsToShow.map(column => { + return ( + + ); + }) } + + + + { data?.items.map((item, index) => ( + + { columnsToShow.map(column => ( + + )) } + + )) } + +
+ { Boolean(column.name) && { column.name } } + +
+ +
+
+
+ ); + + const actionBar = ( + + + + + + ); + + return ( + <> + + + Filtered by: + { filterTags.length !== 0 && ( + + + Reset filters + + ) } + + + { filterTags.map(t => ( + + + { t.name }: + { t.value } + + + + )) } + { filterTags.length === 0 && ( + <> + + + Type: + All + + + + + Age: + 7d + + + + ) } + + + + ); +}; + +export default AdvancedFilter; diff --git a/ui/pages/Transactions.tsx b/ui/pages/Transactions.tsx index aa0f650680..c3b5146b38 100644 --- a/ui/pages/Transactions.tsx +++ b/ui/pages/Transactions.tsx @@ -1,3 +1,4 @@ +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 ( + + { isAdvancedFilterEnabled && ( + + + Advanced filter + + ) } + { pagination.isVisible && } + + ); + })(); + return ( <> { : null - ) } + rightSlot={ rightSlot } stickyEnabled={ !isMobile } /> diff --git a/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_dark-color-mode_base-view-dark-mode-1.png new file mode 100644 index 0000000000..9bab223648 Binary files /dev/null and b/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_default_base-view-dark-mode-1.png b/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_default_base-view-dark-mode-1.png new file mode 100644 index 0000000000..6a6f6d8381 Binary files /dev/null and b/ui/pages/__screenshots__/AdvancedFilter.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/shared/filters/TableColumnFilter.tsx b/ui/shared/filters/TableColumnFilter.tsx index c81cdf179d..92ae533621 100644 --- a/ui/shared/filters/TableColumnFilter.tsx +++ b/ui/shared/filters/TableColumnFilter.tsx @@ -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 { children } - { modifiedChildren } + { modifiedContent } diff --git a/ui/shared/tagGroupSelect/TagGroupSelect.tsx b/ui/shared/tagGroupSelect/TagGroupSelect.tsx index ab170a745c..3d18c26d74 100644 --- a/ui/shared/tagGroupSelect/TagGroupSelect.tsx +++ b/ui/shared/tagGroupSelect/TagGroupSelect.tsx @@ -7,7 +7,7 @@ type Props = { tagSize?: TagProps['size']; } & ( { - value: T; + value?: T; onChange: (value: T) => void; isMulti?: false; } | { @@ -44,7 +44,6 @@ const TagGroupSelect = ({ items, value, isMulti, onChange, tag data-id={ item.id } data-selected={ isSelected } fontWeight={ 500 } - cursor="pointer" onClick={ onItemClick } size={ tagSize } display="inline-flex"