Commit 69e87318 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Allow export token holders to CSV (#1850)

Fixes #1766
parent eef534bb
......@@ -776,6 +776,12 @@ export const RESOURCES = {
path: '/api/v2/config/backend-version',
},
// CSV EXPORT
csv_export_token_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// OTHER
api_v2_key: {
path: '/api/v2/key',
......
......@@ -8,4 +8,8 @@ export type CsvExportParams = {
type: 'logs';
filterType?: 'topic';
filterValue?: string;
} | {
type: 'holders';
filterType?: undefined;
filterValue?: undefined;
}
......@@ -21,9 +21,10 @@ interface Props {
filterType?: CsvExportParams['filterType'] | null;
filterValue?: CsvExportParams['filterValue'] | null;
fileNameTemplate: string;
exportType: CsvExportParams['type'] | undefined;
}
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate }: Props) => {
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate, exportType }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
......@@ -36,10 +37,10 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const url = buildUrl(resource, undefined, {
const url = buildUrl(resource, { hash } as never, {
address_id: hash,
from_period: data.from,
to_period: data.to,
from_period: exportType !== 'holders' ? data.from : null,
to_period: exportType !== 'holders' ? data.to : null,
filter_type: filterType,
filter_value: filterValue,
recaptcha_response: data.reCaptcha,
......@@ -56,11 +57,11 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}
const blob = await response.blob();
downloadBlob(
blob,
`${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }
${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`,
);
const fileName = exportType === 'holders' ?
`${ fileNameTemplate }_${ hash }.csv` :
// eslint-disable-next-line max-len
`${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`;
downloadBlob(blob, fileName);
} catch (error) {
toast({
......@@ -73,7 +74,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
});
}
}, [ fileNameTemplate, hash, resource, filterType, filterValue, toast ]);
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
return (
<FormProvider { ...formApi }>
......@@ -82,8 +83,8 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
<CsvExportFormField name="from" formApi={ formApi }/>
<CsvExportFormField name="to" formApi={ formApi }/>
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<CsvExportFormReCaptcha formApi={ formApi }/>
</Flex>
<Button
......
import { test, expect } from '@playwright/experimental-ct-react';
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
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({ render, page, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
},
};
await mockApiResponse('address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: configs.maskColor,
});
});
const component = await mount(
<TestApp>
<CsvExport/>
</TestApp>,
{ hooksConfig },
);
test('token holders', async({ render, page, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'holders' },
},
};
await mockApiResponse('address', addressMock.token, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('token', tokenMock.tokenInfo, { pathParams: { hash: addressMock.hash } });
await page.waitForResponse('https://www.google.com/recaptcha/api2/**');
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
......
......@@ -15,6 +15,7 @@ import { nbsp } from 'lib/html-entities';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import ContentLoader from 'ui/shared/ContentLoader';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
interface ExportTypeEntity {
......@@ -53,6 +54,11 @@ const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
fileNameTemplate: 'logs',
filterType: 'topic',
},
holders: {
text: 'holders',
resource: 'csv_export_token_holders',
fileNameTemplate: 'holders',
},
};
const isCorrectExportType = (type: string): type is CsvExportParams['type'] => Object.keys(EXPORT_TYPES).includes(type);
......@@ -75,6 +81,15 @@ const CsvExport = () => {
},
});
const tokenQuery = useApiQuery('token', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash) && exportTypeParam === 'holders',
},
});
const isLoading = addressQuery.isPending || (exportTypeParam === 'holders' && tokenQuery.isPending);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
......@@ -111,7 +126,7 @@ const CsvExport = () => {
const content = (() => {
throwOnResourceLoadError(addressQuery);
if (addressQuery.isPending) {
if (isLoading) {
return <ContentLoader/>;
}
......@@ -119,6 +134,7 @@ const CsvExport = () => {
<CsvExportForm
hash={ addressHash }
resource={ exportType.resource }
exportType={ isCorrectExportType(exportTypeParam) ? exportTypeParam : undefined }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ exportType.fileNameTemplate }
......@@ -126,16 +142,33 @@ const CsvExport = () => {
);
})();
return (
<>
<PageTitle
title="Export data to CSV file"
backLink={ backLink }
/>
const description = (() => {
if (isLoading) {
return null;
}
if (exportTypeParam === 'holders') {
return (
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { exportType.text } for token </span>
<TokenEntity
token={ tokenQuery.data }
truncation={ isMobile ? 'constant' : 'dynamic' }
w="min-content"
noCopy
noSymbol
/>
<span> to CSV file. </span>
<span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex>
);
}
return (
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { exportType.text } for address </span>
<AddressEntity
address={{ hash: addressHash, is_contract: true, implementation_name: null }}
address={ addressQuery.data }
truncation={ isMobile ? 'constant' : 'dynamic' }
noCopy
/>
......@@ -144,6 +177,16 @@ const CsvExport = () => {
<span>to CSV file. </span>
<span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex>
);
})();
return (
<>
<PageTitle
title="Export data to CSV file"
backLink={ backLink }
/>
{ description }
{ content }
</>
);
......
......@@ -23,6 +23,7 @@ import * as tokenStubs from 'stubs/token';
import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd';
......@@ -45,6 +46,12 @@ import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
alignItems: 'center',
columnGap: 4,
};
const TokenPageContent = () => {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const [ totalSupplySocket, setTotalSupplySocket ] = React.useState<number>();
......@@ -294,6 +301,25 @@ const TokenPageContent = () => {
</Flex>
);
const tabsRightSlot = React.useMemo(() => {
if (isMobile) {
return null;
}
return (
<>
{ tab === 'holders' && (
<AddressCsvExportLink
address={ hashString }
params={{ type: 'holders' }}
isLoading={ pagination?.isLoading }
/>
) }
{ pagination?.isVisible && <Pagination { ...pagination }/> }
</>
);
}, [ hashString, isMobile, pagination, tab ]);
return (
<>
<TextAd mb={ 6 }/>
......@@ -323,7 +349,8 @@ const TokenPageContent = () => {
<RoutedTabs
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ !isMobile && pagination?.isVisible ? <Pagination { ...pagination }/> : null }
rightSlot={ tabsRightSlot }
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
stickyEnabled={ !isMobile }
/>
) }
......
......@@ -4,6 +4,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -27,6 +28,11 @@ const TokenHoldersContent = ({ holdersQuery, token }: Props) => {
const actionBar = isMobile && holdersQuery.pagination.isVisible && (
<ActionBar mt={ -6 }>
<AddressCsvExportLink
address={ token?.address }
params={{ type: 'holders' }}
isLoading={ holdersQuery.pagination.isLoading }
/>
<Pagination ml="auto" { ...holdersQuery.pagination }/>
</ActionBar>
);
......
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