Commit 872cf535 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #932 from blockscout/csv-filters

csv export filters
parents e1011e30 6db2fd57
......@@ -9,7 +9,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources'
export default function buildUrl<R extends ResourceName>(
resourceName: R,
pathParams?: ResourcePathParams<R>,
queryParams?: Record<string, string | Array<string> | number | undefined>,
queryParams?: Record<string, string | Array<string> | number | null | undefined>,
): string {
const resource: ApiResource = RESOURCES[resourceName];
const baseUrl = isNeedProxy() ? appConfig.baseUrl : (resource.endpoint || appConfig.api.endpoint);
......
export type CsvExportType = 'transactions' | 'internal-transactions' | 'token-transfers' | 'logs';
import type { AddressFromToFilter } from 'types/api/address';
export type CsvExportParams = {
type: 'transactions' | 'internal-transactions' | 'token-transfers';
filterType?: 'address';
filterValue?: AddressFromToFilter;
} | {
type: 'logs';
filterType?: 'topic';
filterValue?: string;
}
......@@ -2,7 +2,7 @@ import { chakra, Icon, Tooltip, Hide, Skeleton, Flex } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import type { CsvExportParams } from 'types/client/address';
import appConfig from 'configs/app/config';
import svgFileIcon from 'icons/files/csv.svg';
......@@ -12,12 +12,12 @@ import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
address: string;
type: CsvExportType;
params: CsvExportParams;
className?: string;
isLoading?: boolean;
}
const AddressCsvExportLink = ({ className, address, type, isLoading }: Props) => {
const AddressCsvExportLink = ({ className, address, params, isLoading }: Props) => {
const isMobile = useIsMobile();
const isInitialLoading = useIsInitialLoading(isLoading);
......@@ -43,7 +43,7 @@ const AddressCsvExportLink = ({ className, address, type, isLoading }: Props) =>
display="inline-flex"
alignItems="center"
whiteSpace="nowrap"
href={ route({ pathname: '/csv-export', query: { type, address } }) }
href={ route({ pathname: '/csv-export', query: { ...params, address } }) }
flexShrink={ 0 }
>
<Icon as={ svgFileIcon } boxSize={{ base: '30px', lg: 6 }}/>
......
......@@ -74,7 +74,12 @@ const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
isActive={ Boolean(filterValue) }
isLoading={ pagination.isLoading }
/>
<AddressCsvExportLink address={ hash } isLoading={ pagination.isLoading } type="internal-transactions" ml={{ base: 2, lg: 'auto' }}/>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'internal-transactions', filterType: 'address', filterValue }}
ml={{ base: 2, lg: 'auto' }}
/>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
</ActionBar>
);
......
......@@ -32,7 +32,11 @@ const AddressLogs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>
const actionBar = (
<ActionBar mt={ -6 } showShadow justifyContent={{ base: 'space-between', lg: 'end' }}>
<AddressCsvExportLink address={ hash } isLoading={ pagination.isLoading } type="logs"/>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'logs' }}
/>
<Pagination ml={{ base: 0, lg: 8 }} { ...pagination }/>
</ActionBar>
);
......
......@@ -272,7 +272,7 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
{ currentAddress && (
<AddressCsvExportLink
address={ currentAddress }
type="token-transfers"
params={{ type: 'token-transfers', filterType: 'address', filterValue: filters.filter }}
ml={{ base: 2, lg: 'auto' }}
isLoading={ isPlaceholderData }
/>
......
......@@ -159,24 +159,27 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
/>
);
return (
<>
{ !isMobile && (
<ActionBar mt={ -6 }>
{ filter }
{ currentAddress && (
const csvExportLink = (
<AddressCsvExportLink
address={ currentAddress }
type="transactions"
params={{ type: 'transactions', filterType: 'address', filterValue }}
ml="auto"
isLoading={ addressTxsQuery.pagination.isLoading }
/>
) }
);
return (
<>
{ !isMobile && (
<ActionBar mt={ -6 }>
{ filter }
{ currentAddress && csvExportLink }
<Pagination { ...addressTxsQuery.pagination } ml={ 8 }/>
</ActionBar>
) }
<TxsContent
filter={ filter }
filterValue={ filterValue }
query={ addressTxsQuery }
currentAddress={ typeof currentAddress === 'string' ? currentAddress : undefined }
enableTimeIncrement
......
......@@ -4,6 +4,7 @@ import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types';
import type { CsvExportParams } from 'types/client/address';
import buildUrl from 'lib/api/buildUrl';
import type { ResourceName } from 'lib/api/resources';
......@@ -17,10 +18,12 @@ import CsvExportFormReCaptcha from './CsvExportFormReCaptcha';
interface Props {
hash: string;
resource: ResourceName;
filterType?: CsvExportParams['filterType'] | null;
filterValue?: CsvExportParams['filterValue'] | null;
fileNameTemplate: string;
}
const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
......@@ -37,6 +40,8 @@ const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
address_id: hash,
from_period: data.from,
to_period: data.to,
filter_type: filterType,
filter_value: filterValue,
recaptcha_response: data.reCaptcha,
});
......@@ -51,7 +56,11 @@ const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
}
const blob = await response.blob();
downloadBlob(blob, `${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }.csv`);
downloadBlob(
blob,
`${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }
${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`,
);
} catch (error) {
toast({
......@@ -64,7 +73,7 @@ const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
});
}
}, [ fileNameTemplate, hash, resource, toast ]);
}, [ fileNameTemplate, hash, resource, filterType, filterValue, toast ]);
return (
<FormProvider { ...formApi }>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import CsvExport from './CsvExport';
const ADDRESS_API_URL = buildApiUrl('address', { hash: addressMock.hash });
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
isReady: true,
},
};
test.beforeEach(async({ page }) => {
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.withName),
}));
});
test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<CsvExport/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
......@@ -2,7 +2,8 @@ import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { CsvExportParams } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
......@@ -21,32 +22,41 @@ interface ExportTypeEntity {
text: string;
resource: ResourceName;
fileNameTemplate: string;
filterType?: CsvExportParams['filterType'];
filterValues?: Readonly<Array<CsvExportParams['filterValue']>>;
}
const EXPORT_TYPES: Record<CsvExportType, ExportTypeEntity> = {
const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
transactions: {
text: 'transactions',
resource: 'csv_export_txs',
fileNameTemplate: 'transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'internal-transactions': {
text: 'internal transactions',
resource: 'csv_export_internal_txs',
fileNameTemplate: 'internal_transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'token-transfers': {
text: 'token transfers',
resource: 'csv_export_token_transfers',
fileNameTemplate: 'token_transfers',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
logs: {
text: 'logs',
resource: 'csv_export_logs',
fileNameTemplate: 'logs',
filterType: 'topic',
},
};
const isCorrectExportType = (type: string): type is CsvExportType => Object.keys(EXPORT_TYPES).includes(type);
const isCorrectExportType = (type: string): type is CsvExportParams['type'] => Object.keys(EXPORT_TYPES).includes(type);
const CsvExport = () => {
const router = useRouter();
......@@ -55,6 +65,8 @@ const CsvExport = () => {
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const filterTypeFromQuery = router.query.filterType?.toString() || null;
const filterValueFromQuery = router.query.filterValue?.toString();
const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash },
......@@ -80,6 +92,19 @@ const CsvExport = () => {
throw Error('Not found', { cause: { status: 404 } });
}
const filterType = filterTypeFromQuery === EXPORT_TYPES[exportType].filterType ? filterTypeFromQuery : null;
const filterValue = (() => {
if (!filterType || !filterValueFromQuery) {
return null;
}
if (EXPORT_TYPES[exportType].filterValues && !EXPORT_TYPES[exportType].filterValues?.includes(filterValueFromQuery)) {
return null;
}
return filterValueFromQuery;
})();
const content = (() => {
if (addressQuery.isError) {
return <DataFetchAlert/>;
......@@ -89,7 +114,15 @@ const CsvExport = () => {
return <ContentLoader/>;
}
return <CsvExportForm hash={ addressHash } resource={ EXPORT_TYPES[exportType].resource } fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }/>;
return (
<CsvExportForm
hash={ addressHash }
resource={ EXPORT_TYPES[exportType].resource }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }
/>
);
})();
return (
......@@ -104,6 +137,7 @@ const CsvExport = () => {
<AddressIcon address={{ hash: addressHash, is_contract: true, implementation_name: null }}/>
<AddressLink hash={ addressHash } type="address" ml={ 2 } truncation={ isMobile ? 'constant' : 'dynamic' }/>
</Address>
{ filterType && filterValue && <span> with applied filter by { filterType } ({ filterValue })</span> }
<span> to CSV file</span>
</Flex>
{ content }
......
import { Box, Show, Hide } from '@chakra-ui/react';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -20,12 +22,14 @@ type Props = {
socketInfoNum?: number;
currentAddress?: string;
filter?: React.ReactNode;
filterValue?: AddressFromToFilter;
enableTimeIncrement?: boolean;
top?: number;
}
const TxsContent = ({
filter,
filterValue,
query,
showBlockInfo = true,
showSocketInfo = true,
......@@ -88,8 +92,14 @@ const TxsContent = ({
paginationProps={ query.pagination }
showPagination={ query.pagination.isVisible }
filterComponent={ filter }
linkSlot={ currentAddress ?
<AddressCsvExportLink address={ currentAddress } type="transactions" ml={ 2 } isLoading={ query.pagination.isLoading }/> : null
linkSlot={ currentAddress ? (
<AddressCsvExportLink
address={ currentAddress }
params={{ type: 'transactions', filterType: 'address', filterValue }}
ml={ 2 }
isLoading={ query.pagination.isLoading }
/>
) : null
}
/>
) : null;
......
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