Async CSV export (#3330)

* add export for address txs

* transfer all export from page to dialog

* remove old route and components

* improve file name and fix loading state of icon button

* basic implementation

* refetch item status

* status indicator for download items

* changes for multichain

* handle too many requests for downloads error

* update button styles and spacing

* fix tests

* disable async export for multichain

* Fix CSV export cancel flow and stabilize context updates

Applied via @cursor push command

* test and review fixes

* design fixes

* refactor file names

---------

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
tom goriunov
2026-04-07 15:51:50 +02:00
committed by GitHub
co-authored by Cursor Agent
parent ae04c20ba2
commit 1baae632f4
112 changed files with 1427 additions and 740 deletions
@@ -0,0 +1,281 @@
import type { JsxStyleProps } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { delay, mapValues, pickBy } from 'es-toolkit';
import React from 'react';
import type { CsvExportDownloadResponse } from '../types/api';
import type { CsvExportType } from '../types/client';
import type { FormFields } from './dialog/types';
import type { NextJsQueryParam } from 'lib/router/types';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import isNeedProxy from 'lib/api/isNeedProxy';
import type { ResourceName, ResourcePathParams } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import getQueryParamString from 'lib/router/getQueryParamString';
import { IconButton } from 'toolkit/chakra/icon-button';
import { toaster } from 'toolkit/chakra/toaster';
import { Tooltip } from 'toolkit/chakra/tooltip';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { downloadBlob } from 'toolkit/utils/file';
import IconSvg from 'ui/shared/IconSvg';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import { useCsvExportContext } from '../utils/context';
import getFileName from '../utils/get-file-name';
import type { StorageItem } from '../utils/storage';
import CsvExportDialog from './dialog/CsvExportDialog';
import CsvExportDialogDescription from './dialog/CsvExportDialogDescription';
interface Props<R extends ResourceName> extends JsxStyleProps {
type: CsvExportType;
resourceName: R;
pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, NextJsQueryParam | null>;
extraParams?: Record<string, string>;
chainData?: ClusterChainConfig;
loadingInitial?: boolean;
loadingSkeleton?: boolean;
periodFilter?: boolean;
}
const CsvExport = <R extends ResourceName>({
chainData,
loadingInitial,
loadingSkeleton,
type,
resourceName,
pathParams,
queryParams,
extraParams,
periodFilter = true,
...rest
}: Props<R>) => {
const [ isPending, setIsPending ] = React.useState(false);
const abortControllerRef = React.useRef<AbortController | null>(null);
const isInitialLoading = useIsInitialLoading(loadingInitial);
const multichainContext = useMultichainContext();
const dialog = useDisclosure();
const recaptcha = useReCaptcha();
const csvExportContext = useCsvExportContext();
const apiFetch = useApiFetch();
const configQuery = useApiQuery('general:config_csv_export', {
queryOptions: {
refetchOnMount: false,
},
chain: chainData || multichainContext?.chain,
});
const chainConfig = chainData?.app_config || multichainContext?.chain.app_config || config;
const recordsLimit = configQuery.data?.limit || 10_000;
const isAsyncDownload = configQuery.data?.async_enabled && !config.features.multichain.isEnabled;
const mergedParams: Record<string, string> = React.useMemo(() => {
return {
...pathParams,
...mapValues(queryParams || {}, (value) => getQueryParamString(value ?? undefined)),
...extraParams,
};
}, [ pathParams, queryParams, extraParams ]);
const fetchFactorySync = React.useCallback((data?: FormFields) => {
return async(recaptchaToken?: string) => {
const chain = chainData || multichainContext?.chain;
const url = buildUrl(resourceName, pathParams, {
...mapValues(data || {}, (value) => dayjs(value).toISOString()),
...queryParams,
}, undefined, chain);
abortControllerRef.current = new AbortController();
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
...(isNeedProxy() && chain ? { 'x-endpoint': chain.app_config.apis.general?.endpoint } : {}),
},
signal: abortControllerRef.current?.signal,
});
if (!response.ok) {
throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
}
return response;
};
}, [ resourceName, pathParams, queryParams, chainData, multichainContext?.chain ]);
const fetchFactoryAsync = React.useCallback((data?: FormFields) => {
return async(recaptchaToken?: string) => {
const chain = chainData || multichainContext?.chain;
abortControllerRef.current = new AbortController();
return apiFetch<typeof resourceName>(resourceName, {
pathParams,
queryParams: {
...mapValues(data || {}, (value) => dayjs(value).toISOString()),
...queryParams,
},
chain,
fetchParams: {
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
signal: abortControllerRef.current?.signal,
},
}) as Promise<CsvExportDownloadResponse>;
};
}, [ apiFetch, chainData, multichainContext?.chain, pathParams, queryParams, resourceName ]);
const downloadFileSync = React.useCallback(async(data?: FormFields) => {
try {
setIsPending(true);
const response = await recaptcha.fetchProtectedResource<Response>(fetchFactorySync(data));
const blob = await response.blob();
downloadBlob(
blob,
getFileName({
type,
params: { ...mergedParams, ...data },
chainConfig,
}),
);
return true;
} catch (error) {
toaster.error({
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
});
} finally {
setIsPending(false);
}
}, [ chainConfig, fetchFactorySync, mergedParams, recaptcha, type ]);
const downloadFileAsync = React.useCallback(async(data?: FormFields) => {
try {
setIsPending(true);
const chain = chainData || multichainContext?.chain;
const downloadResponse = await recaptcha.fetchProtectedResource<CsvExportDownloadResponse>(fetchFactoryAsync(data));
if (downloadResponse && 'request_id' in downloadResponse) {
const newItem: StorageItem = {
request_id: downloadResponse.request_id,
file_id: null,
expires_at: null,
status: 'pending',
type,
params: pickBy({
...mapValues(data || {}, (value) => dayjs(value).toISOString()),
...mergedParams,
chain_id: chain?.id,
}, (value) => value !== '' && value !== undefined && value !== null),
is_highlighted: false,
};
csvExportContext.addItems([ newItem ]);
// we have to wait a little bit to let new item to be added to the list before opening the popup
await delay(200);
csvExportContext.onDialogOpenChange({ open: true });
return true;
}
throw new Error('Something went wrong. Try again later.');
} catch (error) {
const statusCode = getErrorObjStatusCode(error);
if (statusCode === 409) {
toaster.warning({
description: 'You cannot start a new export, please wait until the current exports finish.',
});
return;
}
toaster.error({
title: 'Error',
description: getErrorMessage(error) || 'Something went wrong. Try again later.',
});
} finally {
setIsPending(false);
}
}, [ chainData, csvExportContext, fetchFactoryAsync, mergedParams, multichainContext?.chain, recaptcha, type ]);
const handleButtonClick = React.useCallback(() => {
if (periodFilter) {
dialog.onOpen();
} else {
const downloadFn = isAsyncDownload ? downloadFileAsync : downloadFileSync;
downloadFn();
}
}, [ isAsyncDownload, periodFilter, dialog, downloadFileAsync, downloadFileSync ]);
const handleFormSubmit = React.useCallback(async(data: FormFields) => {
const downloadFn = isAsyncDownload ? downloadFileAsync : downloadFileSync;
const isSuccess = await downloadFn(data);
if (isSuccess) {
dialog.onClose();
}
}, [ isAsyncDownload, downloadFileAsync, downloadFileSync, dialog ]);
const handleDownloadCancel = React.useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, [ ]);
if (!chainConfig.features.csvExport.isEnabled) {
return null;
}
return (
<Box { ...rest }>
<Tooltip
content="This feature is not available due to a reCAPTCHA initialization error. Please contact the project team on Discord to report this issue."
disabled={ !recaptcha.isInitError }
>
<IconButton
size="md"
variant="dropdown"
aria-label="Download CSV"
loading={ isPending }
loadingSkeleton={ isInitialLoading || loadingSkeleton || configQuery.isPending }
onClick={ handleButtonClick }
disabled={ recaptcha.isInitError }
>
<IconSvg name="files/csv"/>
</IconButton>
</Tooltip>
<CsvExportDialog
open={ dialog.open }
onOpenChange={ dialog.onOpenChange }
onFormSubmit={ handleFormSubmit }
onCancel={ handleDownloadCancel }
isAsyncDownload={ isAsyncDownload }
>
<CsvExportDialogDescription
type={ type }
params={ mergedParams }
chainInfo={ chainData }
recordsLimit={ recordsLimit }
/>
</CsvExportDialog>
<ReCaptcha { ...recaptcha } hideWarning/>
</Box>
);
};
export default React.memo(CsvExport);
@@ -0,0 +1,86 @@
import { chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type { FormFields } from './types';
import dayjs from 'lib/date/dayjs';
import { Button } from 'toolkit/chakra/button';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'toolkit/chakra/dialog';
import type { OnOpenChangeHandler } from 'toolkit/hooks/useDisclosure';
import CsvExportFormDateField from './CsvExportFormDateField';
interface Props {
children: React.ReactNode;
open: boolean;
onOpenChange: OnOpenChangeHandler;
onFormSubmit: (data: FormFields) => Promise<void>;
onCancel: () => void;
isAsyncDownload?: boolean;
}
const CsvExportDialog = ({ open, onOpenChange, onFormSubmit, onCancel, children, isAsyncDownload }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
from_period: dayjs().subtract(1, 'day').format('YYYY-MM-DDTHH:mm'),
to_period: dayjs().format('YYYY-MM-DDTHH:mm'),
},
});
const { handleSubmit, formState } = formApi;
const handleOpenChange: OnOpenChangeHandler = React.useCallback(({ open }) => {
if (formState.isSubmitting && !open) {
const confirm = window.confirm('Are you sure you want to close the dialog? The export will be cancelled.');
if (!confirm) {
return;
}
onCancel();
}
onOpenChange({ open });
}, [ onOpenChange, formState.isSubmitting, onCancel ]);
return (
<DialogRoot open={ open } onOpenChange={ handleOpenChange } size={{ lgDown: 'full', lg: 'md' }}>
<DialogContent>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<DialogHeader>
Export data to CSV file
</DialogHeader>
<DialogBody>
{ children }
<Flex
columnGap={ 3 }
rowGap={ 3 }
mt={ 6 }
flexDir={{ base: 'column', lg: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
w="100%"
>
<CsvExportFormDateField name="from_period" formApi={ formApi }/>
<CsvExportFormDateField name="to_period" formApi={ formApi }/>
</Flex>
<Button
variant="solid"
type="submit"
mt={ 6 }
loading={ formState.isSubmitting }
disabled={ Boolean(formState.errors.from_period || formState.errors.to_period) }
>
{ isAsyncDownload ? 'Generate CSV' : 'Download' }
</Button>
</DialogBody>
</chakra.form>
</FormProvider>
</DialogContent>
</DialogRoot>
);
};
export default React.memo(CsvExportDialog);
@@ -0,0 +1,59 @@
import { chakra, Flex, Text } from '@chakra-ui/react';
import React from 'react';
import type { CsvExportType } from '../../types/client';
import type { ClusterChainConfig } from 'types/multichain';
import shortenString from 'lib/shortenString';
import ChainIcon from 'ui/shared/externalChains/ChainIcon';
import getPrefixByFilter from '../../utils/get-prefix-by-filter';
interface Props {
type: CsvExportType;
params?: Record<string, string>;
chainInfo?: ClusterChainConfig;
recordsLimit: number;
}
const CsvExportDialogDescription = ({ type, params, chainInfo, recordsLimit }: Props) => {
const chainInfoElement = chainInfo ? (
<Flex display="inline-flex" alignItems="center" whiteSpace="pre">
<span> on </span>
<ChainIcon data={ chainInfo } mr={ 1 }/>
<span>{ chainInfo.app_config.chain.name }</span>
</Flex>
) : null;
const limitText = recordsLimit.toLocaleString(undefined, { maximumFractionDigits: 3, notation: 'compact' });
if (type === 'token_holders') {
return (
<Text>
<span>Export holders for token </span>
{ params?.token_name && <chakra.span fontWeight="700">{ params.token_name }</chakra.span> }
{ chainInfoElement }
<span> to CSV file. </span>
<span>Limited to the top { limitText } holders by amount held.</span>
</Text>
);
}
if (type.startsWith('address_')) {
const entityText = type.replace('address_', '').replace('_', ' ');
const entityPrefix = getPrefixByFilter(params?.filter_type, params?.filter_value);
return (
<Text as="div">
<span>Export { entityPrefix ? `${ entityPrefix } ` : '' }{ entityText } for address </span>
{ params?.hash && <chakra.span fontWeight="700">{ shortenString(params.hash) }</chakra.span> }
{ chainInfoElement }
<span> to CSV file. </span>
<span>Limited to the last { limitText } { entityText }.</span>
</Text>
);
}
return null;
};
export default React.memo(CsvExportDialogDescription);
@@ -9,42 +9,41 @@ import { FormFieldText } from 'toolkit/components/forms/fields/FormFieldText';
interface Props {
formApi: UseFormReturn<FormFields>;
name: 'from' | 'to';
name: 'from_period' | 'to_period';
}
const CsvExportFormField = ({ formApi, name }: Props) => {
const CsvExportFormDateField = ({ formApi, name }: Props) => {
const { formState, getValues, trigger } = formApi;
const validate = React.useCallback((newValue: string) => {
if (name === 'from') {
const toValue = getValues('to');
if (name === 'from_period') {
const toValue = getValues('to_period');
if (toValue && dayjs(newValue) > dayjs(toValue)) {
return 'Incorrect date';
}
if (formState.errors.to) {
trigger('to');
if (formState.errors.to_period) {
trigger('to_period');
}
} else {
const fromValue = getValues('from');
const fromValue = getValues('from_period');
if (fromValue && dayjs(fromValue) > dayjs(newValue)) {
return 'Incorrect date';
}
if (formState.errors.from) {
trigger('from');
if (formState.errors.from_period) {
trigger('from_period');
}
}
}, [ formState.errors.from, formState.errors.to, getValues, name, trigger ]);
}, [ formState.errors.from_period, formState.errors.to_period, getValues, name, trigger ]);
return (
<FormFieldText<FormFields, typeof name>
name={ name }
inputProps={{ type: 'datetime-local', max: dayjs().format('YYYY-MM-DDTHH:mm') }}
placeholder={ capitalize(name) }
placeholder={ capitalize(name.replace('_period', '')) }
required
rules={{ validate }}
maxW={{ base: 'auto', lg: '220px' }}
/>
);
};
export default React.memo(CsvExportFormField);
export default React.memo(CsvExportFormDateField);
@@ -0,0 +1,4 @@
export interface FormFields {
from_period: string;
to_period: string;
}
@@ -0,0 +1,65 @@
import { Flex, Separator, VStack } from '@chakra-ui/react';
import React from 'react';
import { IconButton } from 'toolkit/chakra/icon-button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { Status } from 'toolkit/chakra/status';
import IconSvg from 'ui/shared/IconSvg';
import { useCsvExportContext } from '../../utils/context';
import CsvExportDownloadsItem from './CsvExportDownloadsItem';
const CsvExportDownloads = () => {
const { dialogOpen, onDialogOpenChange, items } = useCsvExportContext();
if (items.length === 0) {
return null;
}
return (
<PopoverRoot open={ dialogOpen } onOpenChange={ onDialogOpenChange } positioning={{ placement: 'bottom-end' }}>
<PopoverTrigger>
<Flex alignItems="center">
<Flex position="relative">
<IconButton
aria-label="Open list of downloads"
variant="link"
size="2xs"
borderRadius="none"
>
<IconSvg name="download"/>
</IconButton>
{ items.some((item) => item.is_highlighted) && (
<Status
size="sm"
position="absolute"
top="0"
right="0"
/>
) }
</Flex>
<Separator orientation="vertical" mx={ 2 } h={ 4 }/>
</Flex>
</PopoverTrigger>
<PopoverContent w="300px" maxH="400px" overflowY="auto">
<PopoverBody textStyle="sm">
<VStack
separator={ <Separator orientation="horizontal"/> }
alignItems="stretch"
gap={ 3 }
>
{ items.map((item, index) => (
<CsvExportDownloadsItem
key={ `${ item.request_id }_${ item.status }` }
index={ items.length - index }
data={ item }
/>
)) }
</VStack>
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(CsvExportDownloads);
@@ -0,0 +1,224 @@
import { Text, HStack, VStack, Spinner } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { upperFirst } from 'es-toolkit';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import config from 'configs/app';
import multichainConfig from 'configs/multichain';
import isNeedProxy from 'lib/api/isNeedProxy';
import dayjs from 'lib/date/dayjs';
import shortenString from 'lib/shortenString';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import { Status } from 'toolkit/chakra/status';
import { SECOND } from 'toolkit/utils/consts';
import IconSvg from 'ui/shared/IconSvg';
import getPrefixByFilter from '../../utils/get-prefix-by-filter';
import * as storage from '../../utils/storage';
import type { StorageItem } from '../../utils/storage';
interface Props {
index: number;
data: StorageItem;
}
// TODO @tom2drum check downloads on multichain
const CsvExportDownloadsItem = ({ index, data }: Props) => {
const viewItemTimeoutRef = React.useRef<number>(null);
const [ expiresText, setExpiresText ] = React.useState<string>(
data.expires_at && dayjs().isBefore(dayjs(data.expires_at)) ? dayjs(data.expires_at).fromNow() : 'soon',
);
const queryClient = useQueryClient();
const chainData = data.params.chain_id ? multichainConfig()?.chains.find(({ id }) => id === data.params.chain_id) : undefined;
React.useEffect(() => {
if (data.status !== 'completed' || !data.expires_at) {
return;
}
const intervalId = window.setInterval(() => {
const isExpired = dayjs().isAfter(dayjs(data.expires_at));
if (isExpired) {
window.clearInterval(intervalId);
queryClient.fetchQuery({
queryKey: [ 'general:csv_exports_item', data.request_id ],
});
}
setExpiresText(dayjs(data.expires_at).fromNow());
}, SECOND);
return () => {
window.clearInterval(intervalId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- queryClient is intentionally omitted to avoid restarting interval on client identity changes
}, [ data.expires_at, data.status ]);
const markItemAsViewed = React.useCallback(() => {
storage.removeItem(data.request_id);
queryClient.setQueryData<StorageItem>([ 'general:csv_exports_item', data.request_id ], (prevData) => {
if (!prevData) {
return;
}
return {
...prevData,
is_highlighted: false,
};
});
}, [ data.request_id, queryClient ]);
const handleInViewChange = React.useCallback((inView: boolean) => {
if (inView) {
viewItemTimeoutRef.current = window.setTimeout(() => {
markItemAsViewed();
}, SECOND);
}
}, [ markItemAsViewed ]);
const { ref } = useInView({
triggerOnce: true,
skip: ![ 'failed', 'expired' ].includes(data.status),
threshold: 1,
onChange: handleInViewChange,
});
React.useEffect(() => {
return () => {
window.clearTimeout(viewItemTimeoutRef.current ?? undefined);
};
}, []);
const { title, color, description } = (() => {
switch (data.status) {
case 'pending': {
return {
title: `Export #${ index } in progress...`,
color: undefined,
description: 'We collect data and prepare file for export.',
};
}
case 'completed': {
return {
title: `CSV file #${ index } is ${ !data.is_highlighted ? 'downloaded' : 'ready' }`,
color: data.is_highlighted ? 'green.500' : undefined,
description: data.is_highlighted ? `The file will expire ${ expiresText }. It will be deleted after download.` : undefined,
};
}
case 'failed': {
return {
title: `Export #${ index } has failed`,
color: 'red.600',
description: 'The export failed. Please try again.',
};
}
case 'expired': {
return {
title: `Export #${ index } has expired`,
color: 'red.600',
description: 'The file storage time has expired. Try generating the report again.',
};
}
}
})();
const exportDetailsText = (() => {
const chainText = chainData ? `on ${ chainData.name }` : undefined;
const periodText = data.params.from_period && data.params.to_period ?
`from ${ dayjs(data.params.from_period).format('lll') } to ${ dayjs(data.params.to_period).format('lll') }` :
undefined;
if (data.type === 'token_holders') {
return [
'Token holders for token',
data.params.token_name,
chainText,
periodText,
].filter(Boolean).join(' ');
}
if (data.type.startsWith('address_')) {
const entityPrefix = getPrefixByFilter(data.params?.filter_type, data.params?.filter_value);
return upperFirst([
entityPrefix,
data.type.replace('address_', '').replace('_', ' '),
'for',
shortenString(data.params.hash),
chainText,
periodText,
].filter(Boolean).join(' '));
}
if (data.type === 'advanced_filters') {
const dateText = data.params.created_at ? `at ${ dayjs(data.params.created_at).format('lll') }` : undefined;
return [
'Filtered txs',
chainText,
dateText,
].filter(Boolean).join(' ');
}
})();
const downloadLink = (() => {
if (data.status !== 'completed' || !data.file_id) {
return;
}
if (isNeedProxy()) {
return `${ (chainData?.app_config || config).apis.general?.endpoint ?? '' }/downloadFile?id=${ data.file_id }`;
}
return `/downloadFile?id=${ data.file_id }`;
})();
return (
<HStack alignItems="flex-start" ref={ ref }>
{ data.status === 'pending' ? <Spinner flexShrink={ 0 }/> : <IconSvg name="files/csv" boxSize={ 5 } color={ color }/> }
<VStack alignItems="flex-start">
<HStack justifyContent="space-between" w="full">
<Text fontWeight={ 600 } color={ color }>
{ title }
</Text>
{ data.is_highlighted && <Status/> }
</HStack>
{ description && (
<Text color="text.secondary">
{ description }
</Text>
) }
{ exportDetailsText && (
<Text
color="text.secondary"
fontWeight={ 600 }
opacity={ data.status === 'completed' && !data.is_highlighted ? 0.3 : 1 }
>
{ exportDetailsText }
</Text>
) }
{ data.status === 'completed' && data.is_highlighted && (
<Link
href={ downloadLink }
external
noIcon
>
<Button
variant="outline"
size="sm"
mt={ 2 }
onClick={ markItemAsViewed }
>
Download CSV
</Button>
</Link>
) }
</VStack>
</HStack>
);
};
export default React.memo(CsvExportDownloadsItem);
@@ -0,0 +1,58 @@
import type { StorageItem } from '../utils/storage';
export const itemCompleted = {
request_id: 'ba3e4b01-7921-4e34-a530-4ac3082b0a92',
file_id: 'IsPUL6HibKAFhrNs6Mwl',
status: 'completed',
expires_at: '2022-11-11T18:22:44Z',
type: 'address_txs',
params: {
from_period: '2026-03-30T18:22:00.000Z',
to_period: '2026-03-31T18:22:00.000Z',
hash: '0x774e9146e400D5cAe26aA1C90eee9225FFC2FF51',
},
is_highlighted: true,
} satisfies StorageItem;
export const itemPending = {
request_id: '2e16be21-ac33-40ea-98bf-9c6c4da160f2',
file_id: null,
expires_at: null,
status: 'pending',
type: 'address_token_transfers',
params: {
from_period: '2026-03-30T18:38:00.000Z',
to_period: '2026-03-31T18:38:00.000Z',
hash: '0x774e9146e400D5cAe26aA1C90eee9225FFC2FF51',
filter_type: 'address',
filter_value: 'to',
},
is_highlighted: false,
} satisfies StorageItem;
export const itemFailed = {
request_id: '6d3b431f-a339-4f66-99d9-264435cdb895',
file_id: null,
expires_at: null,
status: 'failed',
type: 'advanced_filters',
params: {
transaction_types: 'ERC-20,ERC-721,ERC-1155,ERC-404,contract_creation,contract_interaction',
to_address_hashes_to_include: '0x774e9146e400D5cAe26aA1C90eee9225FFC2FF51',
created_at: '2026-03-31T18:40:09.159Z',
},
is_highlighted: true,
} satisfies StorageItem;
export const itemExpired = {
request_id: 'd5daa2af-41a6-4f99-a324-7146994e6f47',
file_id: null,
expires_at: '2022-11-10T18:22:44Z',
status: 'pending',
type: 'token_holders',
params: {
hash: '0x63eD0d9139edAF9d4195a4fA0879cb33C0AB8D70',
token_name: 'R2Credential',
},
is_highlighted: false,
} satisfies StorageItem;
+16
View File
@@ -0,0 +1,16 @@
export type CsvExportItemStatus = 'pending' | 'completed' | 'failed';
export interface CsvExportItemResponse {
file_id: string | null;
status: CsvExportItemStatus;
expires_at: string | null;
}
export interface CsvExportDownloadResponse {
request_id: string;
}
export interface CsvExportConfig {
limit: number;
async_enabled: boolean;
}
@@ -0,0 +1,12 @@
import type { CsvExportItemStatus } from './api';
export type CsvExportType =
'address_txs' |
'address_internal_txs' |
'address_token_transfers' |
'address_logs' |
'token_holders' |
'address_epoch_rewards' |
'advanced_filters';
export type CsvExportDownloadStatus = CsvExportItemStatus | 'expired';
@@ -0,0 +1,129 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { queryOptions, useQueries } from '@tanstack/react-query';
import React from 'react';
import type { CsvExportItemResponse } from '../types/api';
import useApiFetch from 'lib/api/useApiFetch';
import dayjs from 'lib/date/dayjs';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import type { OnOpenChangeHandler } from 'toolkit/hooks/useDisclosure';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { SECOND } from 'toolkit/utils/consts';
import { isBrowser } from 'toolkit/utils/isBrowser';
import type { StorageItem } from './storage';
import * as storage from './storage';
interface CsvExportContextProviderProps {
children: React.ReactNode;
}
export interface TCsvExportContext {
dialogOpen: boolean;
onDialogOpenChange: OnOpenChangeHandler;
items: Array<StorageItem>;
addItems: (items: Array<StorageItem>) => void;
}
export const CsvExportContext = React.createContext<TCsvExportContext | null>(null);
export function CsvExportContextProvider({ children }: CsvExportContextProviderProps) {
const dialog = useDisclosure();
const apiFetch = useApiFetch();
const [ items, setItems ] = React.useState<Array<StorageItem>>(isBrowser() ? storage.getItems() : []);
const queriesOptions = React.useMemo(() => {
return items.map((item) => (queryOptions({
queryKey: [ 'general:csv_exports_item', item.request_id ],
queryFn: async({ signal }) => {
try {
if (item.status === 'pending') {
const response = await (apiFetch('general:csv_exports_item', {
pathParams: { id: item.request_id },
fetchParams: { signal },
}) as Promise<CsvExportItemResponse>);
if (response.status !== 'pending') {
const newItem = {
...item,
status: response.status,
expires_at: response.expires_at,
file_id: response.file_id,
is_highlighted: true,
};
storage.updateItems([ newItem ]);
return newItem;
}
}
const isExpired = item.status !== 'expired' && item.expires_at && dayjs().isAfter(dayjs(item.expires_at));
if (isExpired) {
const newItem = {
...item,
status: 'expired' as const,
is_highlighted: true,
};
storage.updateItems([ newItem ]);
return newItem;
}
} catch (error) {
const statusCode = getErrorObjStatusCode(error);
if (statusCode === 404) {
const newItem = {
...item,
status: 'expired' as const,
is_highlighted: true,
};
storage.updateItems([ newItem ]);
return newItem;
}
}
return item;
},
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'pending' ? 10 * SECOND : false;
},
refetchOnMount: false,
})));
}, [ items, apiFetch ]);
const combineQueriesResult = React.useCallback((results: Array<UseQueryResult<StorageItem | undefined, Error>>) => {
return results.map(({ data }) => data).filter(Boolean);
}, []);
const queriesResult = useQueries({
queries: queriesOptions,
combine: combineQueriesResult,
});
const addItems = React.useCallback((items: Array<StorageItem>) => {
setItems((prev) => ([ ...items, ...prev ]));
storage.addItems(items);
}, [ ]);
const value = React.useMemo(() => {
return {
dialogOpen: dialog.open,
onDialogOpenChange: dialog.onOpenChange,
items: queriesResult,
addItems,
};
}, [ dialog.open, dialog.onOpenChange, queriesResult, addItems ]);
return (
<CsvExportContext.Provider value={ value }>
{ children }
</CsvExportContext.Provider>
);
}
export function useCsvExportContext() {
const context = React.useContext(CsvExportContext);
if (!context) {
throw new Error('useCsvExportContext must be used within a CsvExportContextProvider');
}
return context;
}
@@ -0,0 +1,47 @@
import type { CsvExportType } from '../types/client';
import type config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import getPrefixByFilter from './get-prefix-by-filter';
interface Params {
type: CsvExportType;
params: Record<string, string>;
chainConfig?: typeof config;
}
export default function getFileName({ type, params, chainConfig }: Params): string {
const chainText = chainConfig?.chain.name ? `${ chainConfig.chain.name.replace(' ', '_').toLowerCase() }` : '';
if (type === 'token_holders') {
return [
chainText,
'token_holders',
params.hash,
].filter(Boolean).join('_') + '.csv';
}
if (type.startsWith('address_')) {
const dateText = params.from_period && params.to_period ? `from_${ params.from_period }_to_${ params.to_period }` : '';
const entityPrefix = getPrefixByFilter(params?.filter_type, params?.filter_value);
return [
chainText,
entityPrefix,
type,
params.hash,
dateText,
].filter(Boolean).join('_') + '.csv';
}
if (type === 'advanced_filters') {
return [
chainText,
'filtered_txs',
dayjs().format('YYYY-MM-DD-HH-mm-ss'),
].filter(Boolean).join('_') + '.csv';
}
return 'data.csv';
}
@@ -0,0 +1,11 @@
export default function getPrefixByFilter(filterType?: string, filterValue?: string): string {
if (filterType === 'address') {
if (filterValue === 'from') {
return 'outgoing';
}
if (filterValue === 'to') {
return 'incoming';
}
}
return '';
}
@@ -0,0 +1,66 @@
import * as v from 'valibot';
export const STORAGE_KEY = 'csv_export_downloads';
const ITEMS_LIMIT = 20;
export const itemSchema = v.object({
request_id: v.string(),
file_id: v.nullable(v.string()),
status: v.union([ v.literal('pending'), v.literal('completed'), v.literal('failed'), v.literal('expired') ]),
expires_at: v.nullable(v.string()),
type: v.union([
v.literal('address_txs'),
v.literal('address_internal_txs'),
v.literal('address_token_transfers'),
v.literal('address_logs'),
v.literal('token_holders'),
v.literal('address_epoch_rewards'),
v.literal('advanced_filters'),
]),
params: v.record(v.string(), v.string()),
is_highlighted: v.optional(v.boolean()),
});
export type StorageItem = v.InferOutput<typeof itemSchema>;
export function getItems() {
try {
const items = localStorage.getItem(STORAGE_KEY);
if (!items) {
return [];
}
return v.parse(v.array(itemSchema), JSON.parse(items));
} catch (error) {
return [];
}
}
export function addItems(items: Array<StorageItem>) {
const currentItems = getItems();
const newItems = [ ...items, ...currentItems ].slice(0, ITEMS_LIMIT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems));
}
export function updateItems(items: Array<StorageItem>) {
if (items.length === 0) {
return;
}
const currentItems = getItems();
const newItems = currentItems.map((item) => {
const itemToUpdate = items.find((itemToUpdate) => itemToUpdate.request_id === item.request_id);
if (itemToUpdate) {
return {
...item,
...itemToUpdate,
};
}
return item;
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems));
}
export function removeItem(id: string) {
const items = getItems();
const newItems = items.filter((item) => item.request_id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems));
}
@@ -59,7 +59,7 @@ module.exports = {
{
userAgent: '*',
allow: '/',
disallow: ['/auth/*', '/login', '/chakra', '/sprite', '/account/*', '/csv-export'],
disallow: ['/auth/*', '/login', '/chakra', '/sprite', '/account/*'],
},
],
},
@@ -71,7 +71,6 @@ module.exports = {
'/login',
'/sprite',
'/chakra',
'/csv-export',
],
transform: async(config, path) => {
switch (path) {
+1
View File
@@ -302,6 +302,7 @@ export default tseslint.config(
'/types/',
[ '/^nextjs/' ],
[
'/^client/',
'/^configs/',
'/^data/',
'/^deploy/',
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 17a1 1 0 1 1 0 2H4a1 1 0 0 1 0-2h12ZM10 1.11c.525 0 .95.332.95.741v10.046c.001.212.257.317.406.168l4.022-4.022a.95.95 0 0 1 1.343 1.345l-6.048 6.049a.951.951 0 0 1-1.345 0l-6.05-6.049a.951.951 0 0 1 1.345-1.345l4.02 4.022c.15.15.406.044.407-.168V1.851c0-.409.425-.741.95-.741Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+3 -3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

+9 -1
View File
@@ -1,4 +1,5 @@
import type { ApiResource } from '../../types';
import type { CsvExportItemResponse, CsvExportConfig } from 'client/features/csv-export/types/api';
import type { AdvancedFilterParams, AdvancedFilterResponse, AdvancedFilterMethodsResponse } from 'types/api/advancedFilter';
import type {
ArbitrumL2TxnBatchesItem,
@@ -7,7 +8,7 @@ import type {
import type { Blob } from 'types/api/blobs';
import type { Block } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendConfig, BackendVersionConfig, CeloConfig, ContractLanguagesConfig, CsvExportConfig } from 'types/api/configs';
import type { BackendConfig, BackendVersionConfig, CeloConfig, ContractLanguagesConfig } from 'types/api/configs';
import type { HotContractsFilters, HotContractsResponse, HotContractsSorting } from 'types/api/contracts';
import type { DepositsResponse, DepositsCounters } from 'types/api/deposits';
import type { CeloEpochDetails, CeloEpochElectionRewardDetailsResponse, CeloEpochListResponse } from 'types/api/epochs';
@@ -252,6 +253,12 @@ export const GENERAL_API_MISC_RESOURCES = {
path: '/api/v2/advanced-filters/csv',
},
// CSV EXPORT
csv_exports_item: {
path: '/api/v2/csv-exports/:id',
pathParams: [ 'id' as const ],
},
// CONFIGS
config_backend: {
path: '/api/v2/config/backend',
@@ -326,6 +333,7 @@ R extends 'general:deposits' ? DepositsResponse :
R extends 'general:deposits_counters' ? DepositsCounters :
R extends 'general:advanced_filter' ? AdvancedFilterResponse :
R extends 'general:advanced_filter_methods' ? AdvancedFilterMethodsResponse :
R extends 'general:csv_exports_item' ? CsvExportItemResponse :
never;
/* eslint-enable @stylistic/indent */
-2
View File
@@ -41,7 +41,6 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/withdrawals': 'Root page',
'/txn-withdrawals': 'Root page',
'/visualize/sol2uml': 'Regular page',
'/csv-export': 'Regular page',
'/deposits': 'Root page',
'/output-roots': 'Root page',
'/dispute-games': 'Root page',
@@ -77,7 +76,6 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/chain/[chain_slug_or_id]/block/[height_or_hash]': 'Regular page',
'/chain/[chain_slug_or_id]/block/countdown': 'Regular page',
'/chain/[chain_slug_or_id]/block/countdown/[height]': 'Regular page',
'/chain/[chain_slug_or_id]/csv-export': 'Regular page',
'/chain/[chain_slug_or_id]/op/[hash]': 'Regular page',
'/chain/[chain_slug_or_id]/token/[hash]': 'Regular page',
'/chain/[chain_slug_or_id]/token/[hash]/instance/[id]': 'Regular page',
-2
View File
@@ -44,7 +44,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/withdrawals': DEFAULT_TEMPLATE,
'/txn-withdrawals': DEFAULT_TEMPLATE,
'/visualize/sol2uml': DEFAULT_TEMPLATE,
'/csv-export': DEFAULT_TEMPLATE,
'/deposits': DEFAULT_TEMPLATE,
'/output-roots': DEFAULT_TEMPLATE,
'/dispute-games': DEFAULT_TEMPLATE,
@@ -80,7 +79,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/chain/[chain_slug_or_id]/block/[height_or_hash]': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/block/countdown': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/block/countdown/[height]': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/csv-export': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/op/[hash]': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/token/[hash]': DEFAULT_TEMPLATE,
'/chain/[chain_slug_or_id]/token/[hash]/instance/[id]': DEFAULT_TEMPLATE,
-2
View File
@@ -46,7 +46,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/withdrawals': '%network_name% withdrawals - track on %network_name% explorer',
'/txn-withdrawals': `${ layerLabels.current } to ${ layerLabels.parent } message relayer`,
'/visualize/sol2uml': '%network_name% Solidity UML diagram',
'/csv-export': '%network_name% export data to CSV',
'/deposits': '%network_name% deposits - track on %network_name% explorer',
'/output-roots': '%network_name% output roots',
'/dispute-games': '%network_name% dispute games',
@@ -82,7 +81,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/chain/[chain_slug_or_id]/block/[height_or_hash]': '%network_name% block %height_or_hash% details',
'/chain/[chain_slug_or_id]/block/countdown': '%network_name% block countdown index',
'/chain/[chain_slug_or_id]/block/countdown/[height]': '%network_name% block %height% countdown',
'/chain/[chain_slug_or_id]/csv-export': '%network_name% export data to CSV',
'/chain/[chain_slug_or_id]/op/[hash]': '%network_name% user operation %hash% details',
'/chain/[chain_slug_or_id]/token/[hash]': '%network_name% token details',
'/chain/[chain_slug_or_id]/token/[hash]/instance/[id]': '%network_name% token NFT instance',
-2
View File
@@ -39,7 +39,6 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/withdrawals': 'Withdrawals',
'/txn-withdrawals': 'Txn withdrawals',
'/visualize/sol2uml': 'Solidity UML diagram',
'/csv-export': 'Export data to CSV file',
'/deposits': 'Deposits',
'/output-roots': 'Output roots',
'/dispute-games': 'Dispute games',
@@ -75,7 +74,6 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/chain/[chain_slug_or_id]/block/[height_or_hash]': 'Chain block details',
'/chain/[chain_slug_or_id]/block/countdown': 'Chain block countdown index',
'/chain/[chain_slug_or_id]/block/countdown/[height]': 'Chain block countdown',
'/chain/[chain_slug_or_id]/csv-export': 'Chain export data to CSV',
'/chain/[chain_slug_or_id]/op/[hash]': 'Chain user operation details',
'/chain/[chain_slug_or_id]/token/[hash]': 'Chain token details',
'/chain/[chain_slug_or_id]/token/[hash]/instance/[id]': 'Chain token NFT instance',
+1
View File
@@ -0,0 +1 @@
export type NextJsQueryParam = string | Array<string> | undefined;
+5
View File
@@ -59,6 +59,11 @@ export const chainA = {
},
},
},
services: {
reCaptchaV2: {
siteKey: 'xxx',
},
},
},
} as unknown as ClusterChainConfig;
-8
View File
@@ -68,14 +68,6 @@ export const apiDocs: Guard = (chainConfig: typeof config) => async() => {
}
};
export const csvExport: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.csvExport.isEnabled) {
return {
notFound: true,
};
}
};
export const stats: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.stats.isEnabled) {
return {
-1
View File
@@ -12,7 +12,6 @@ export const userOps = factory([ guards.userOps ]);
export const marketplace = factory([ guards.marketplace ]);
export const marketplaceEssentialDapp = factory([ guards.marketplaceEssentialDapp ]);
export const apiDocs = factory([ guards.apiDocs ]);
export const csvExport = factory([ guards.csvExport ]);
export const stats = factory([ guards.stats ]);
export const suave = factory([ guards.suave ]);
export const nameServiceEns = factory([ guards.nameServiceEns ]);
-1
View File
@@ -5,4 +5,3 @@ export const base = factoryMultichain([ guards.multichain ]);
export const userOps = factoryMultichain([ guards.multichain, guards.userOps ]);
export const accountsLabelSearch = factoryMultichain([ guards.multichain, guards.accountsLabelSearch ]);
export const advancedFilter = factoryMultichain([ guards.multichain, guards.advancedFilter ]);
export const csvExport = factoryMultichain([ guards.multichain, guards.csvExport ]);
-2
View File
@@ -50,7 +50,6 @@ declare module "nextjs-routes" {
| DynamicRoute<"/chain/[chain_slug_or_id]/block/[height_or_hash]", { "chain_slug_or_id": string; "height_or_hash": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/block/countdown", { "chain_slug_or_id": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/block/countdown/[height]", { "chain_slug_or_id": string; "height": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/csv-export", { "chain_slug_or_id": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/op/[hash]", { "chain_slug_or_id": string; "hash": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/token/[hash]", { "chain_slug_or_id": string; "hash": string }>
| DynamicRoute<"/chain/[chain_slug_or_id]/token/[hash]/instance/[id]", { "chain_slug_or_id": string; "hash": string; "id": string }>
@@ -59,7 +58,6 @@ declare module "nextjs-routes" {
| StaticRoute<"/chakra">
| StaticRoute<"/contract-verification">
| DynamicRoute<"/cross-chain-tx/[id]", { "id": string }>
| StaticRoute<"/csv-export">
| StaticRoute<"/deposits">
| StaticRoute<"/dispute-games">
| StaticRoute<"/ecosystems">
+4 -1
View File
@@ -11,6 +11,7 @@ import type { NextPageWithLayout } from 'nextjs/types';
import type { Route } from 'nextjs-routes';
import PageMetadata from 'nextjs/PageMetadata';
import { CsvExportContextProvider } from 'client/features/csv-export/utils/context';
import config from 'configs/app';
import getSocketUrl from 'lib/api/getSocketUrl';
import useQueryClientConfig from 'lib/api/useQueryClientConfig';
@@ -115,7 +116,9 @@ function MyApp({ Component, pageProps, router }: AppPropsWithLayout) {
<RewardsProvider>
<MarketplaceContextProvider>
<SettingsContextProvider>
{ content }
<CsvExportContextProvider>
{ content }
</CsvExportContextProvider>
</SettingsContextProvider>
</MarketplaceContextProvider>
</RewardsProvider>
@@ -1,21 +0,0 @@
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import { MultichainProvider } from 'lib/contexts/multichain';
import CsvExport from 'ui/pages/CsvExport';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chain/[chain_slug_or_id]/csv-export">
<MultichainProvider>
<CsvExport/>
</MultichainProvider>
</PageNextJs>
);
};
export default Page;
export { csvExport as getServerSideProps } from 'nextjs/getServerSideProps/multichain';
-18
View File
@@ -1,18 +0,0 @@
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import CsvExport from 'ui/pages/CsvExport';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/csv-export">
<CsvExport/>
</PageNextJs>
);
};
export default Page;
export { csvExport as getServerSideProps } from 'nextjs/getServerSideProps/main';
+4 -1
View File
@@ -7,6 +7,7 @@ import { mainnet } from 'wagmi/chains';
import type { Props as PageProps } from 'nextjs/getServerSideProps/handlers';
import { CsvExportContextProvider } from 'client/features/csv-export/utils/context';
import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { MarketplaceContext } from 'lib/contexts/marketplace';
@@ -81,7 +82,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig! }>
<RewardsContextProvider>
{ children }
<CsvExportContextProvider>
{ children }
</CsvExportContextProvider>
</RewardsContextProvider>
</WagmiProvider>
</GrowthBookProvider>
+1
View File
@@ -50,6 +50,7 @@
| "delete"
| "docs"
| "dots"
| "download"
| "edit"
| "email"
| "ENS"
+12
View File
@@ -0,0 +1,12 @@
import { Status as ChakraStatus } from '@chakra-ui/react';
import * as React from 'react';
export interface StatusProps extends ChakraStatus.RootProps {}
export const Status = React.forwardRef<HTMLDivElement, StatusProps>(
function Status(props, ref) {
return (
<ChakraStatus.Root ref={ ref } { ...props }/>
);
},
);
+7 -1
View File
@@ -3,10 +3,16 @@ import type { UseDisclosureProps } from '@chakra-ui/react';
import { useDisclosure as useDisclosureChakra } from '@chakra-ui/react';
import React from 'react';
export interface OpenChangeDetails {
open: boolean;
}
export type OnOpenChangeHandler = (details: OpenChangeDetails) => void;
export function useDisclosure(props?: UseDisclosureProps) {
const { open, onOpen, onClose, onToggle } = useDisclosureChakra(props);
const onOpenChange = React.useCallback(({ open }: { open: boolean }) => {
const onOpenChange = React.useCallback(({ open }: OpenChangeDetails) => {
if (open) {
onOpen();
} else {
+1
View File
@@ -29,6 +29,7 @@ export * from '../../chakra/rating';
export * from '../../chakra/select';
export * from '../../chakra/skeleton';
export * from '../../chakra/slider';
export * from '../../chakra/status';
export * from '../../chakra/switch';
export * from '../../chakra/table';
export * from '../../chakra/tabs';
+1 -1
View File
@@ -283,7 +283,7 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
bg: {
DEFAULT: { value: '{colors.alert.bg.info}' },
info: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
warning: { value: '{colors.alert.bg.warning}' },
warning: { value: { _light: '{colors.orange.100}', _dark: '{colors.orange.900}' } },
success: { value: '{colors.alert.bg.success}' },
error: { value: '{colors.alert.bg.error}' },
loading: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
+8
View File
@@ -318,6 +318,14 @@ export const recipe = defineRecipe({
_expanded: {
color: 'hover',
},
_loading: {
opacity: 1,
'& .chakra-spinner': {
borderColor: 'selected.option.bg',
borderBottomColor: 'spinner.track',
borderInlineStartColor: 'spinner.track',
},
},
},
pagination: {
borderWidth: '2px',
+2
View File
@@ -26,6 +26,7 @@ import { recipe as separator } from './separator.recipe';
import { recipe as skeleton } from './skeleton.recipe';
import { recipe as spinner } from './spinner.recipe';
import { recipe as stat } from './stat.recipe';
import { recipe as status } from './status.recipe';
import { recipe as switchRecipe } from './switch.recipe';
import { recipe as table } from './table.recipe';
import { recipe as tabs } from './tabs.recipe';
@@ -67,6 +68,7 @@ export const slotRecipes = {
ratingGroup,
select,
stat,
status,
'switch': switchRecipe,
table,
tabs,
+9
View File
@@ -7,6 +7,15 @@ export const recipe = defineRecipe({
outline: '0',
position: 'relative',
appearance: 'textfield',
// fix for datetime-local input in mobile Safari
'&[type="datetime-local"]': {
appearance: 'none',
},
'&::-webkit-date-and-time-value': {
textAlign: 'left',
},
textAlign: 'start',
borderRadius: 'base',
height: 'var(--input-height)',
+44
View File
@@ -0,0 +1,44 @@
import { defineSlotRecipe } from '@chakra-ui/react';
export const recipe = defineSlotRecipe({
className: 'chakra-status',
slots: [ 'root', 'indicator' ],
base: {
root: {
display: 'block',
borderRadius: 'full',
borderColor: 'bg.primary',
bg: 'orange.400',
forcedColorAdjust: 'none',
flexShrink: 0,
},
},
variants: {
size: {
xs: {
root: {
boxSize: '6px',
borderWidth: '1px',
},
},
sm: {
root: {
boxSize: '8px',
borderWidth: '1px',
},
},
md: {
root: {
boxSize: '10px',
borderWidth: '1px',
},
},
},
},
defaultVariants: {
size: 'md',
},
});
-4
View File
@@ -7,10 +7,6 @@ export interface BackendVersionConfig {
backend_version: string;
}
export interface CsvExportConfig {
limit: number;
}
export interface CeloConfig {
l2_migration_block: number;
}
-19
View File
@@ -1,19 +0,0 @@
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;
} | {
type: 'holders';
filterType?: undefined;
filterValue?: undefined;
} | {
type: 'epoch-rewards';
filterType?: undefined;
filterValue?: undefined;
};
-55
View File
@@ -1,55 +0,0 @@
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { CsvExportParams } from 'types/client/address';
import type { ClusterChainConfig } from 'types/multichain';
import { route } from 'nextjs/routes';
import config from 'configs/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Link } from 'toolkit/chakra/link';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
address: string;
params: CsvExportParams;
className?: string;
isLoading?: boolean;
chainData?: ClusterChainConfig;
}
const AddressCsvExportLink = ({ className, address, params, isLoading, chainData }: Props) => {
const isMobile = useIsMobile();
const isInitialLoading = useIsInitialLoading(isLoading);
const multichainContext = useMultichainContext();
const chainConfig = chainData?.app_config || multichainContext?.chain.app_config || config;
if (!chainConfig.features.csvExport.isEnabled) {
return null;
}
return (
<Tooltip disabled={ !isMobile } content="Download CSV">
<Link
className={ className }
whiteSpace="nowrap"
href={ route({ pathname: '/csv-export', query: { ...params, address } }, { chain: chainData ?? multichainContext?.chain }) }
flexShrink={ 0 }
loading={ isInitialLoading }
minW={ 8 }
justifyContent="center"
textStyle="sm"
>
<IconSvg name="files/csv" boxSize={ 5 }/>
<chakra.span ml={ 1 } hideBelow="lg">Download</chakra.span>
</Link>
</Tooltip>
);
};
export default React.memo(chakra(AddressCsvExportLink));
+7 -7
View File
@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { EPOCH_REWARD_ITEM } from 'stubs/address';
@@ -12,7 +13,6 @@ import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressEpochRewardsListItem from './epochRewards/AddressEpochRewardsListItem';
type Props = {
@@ -70,15 +70,15 @@ const AddressEpochRewards = ({ shouldRender = true, isQueryEnabled = true }: Pro
const actionBar = (
<ActionBar mt={ -6 }>
<AddressCsvExportLink
address={ hash }
isLoading={ rewardsQuery.pagination.isLoading }
params={{ type: 'epoch-rewards' }}
ml={{ lg: 'auto' }}
<CsvExport
type="address_epoch_rewards"
resourceName="general:address_csv_export_celo_election_rewards"
pathParams={{ hash }}
loadingInitial={ rewardsQuery.pagination.isLoading }
/>
{ rewardsQuery.pagination.isVisible && (
<Pagination
ml={{ base: 0, lg: 8 }}
ml="auto"
{ ...rewardsQuery.pagination }
/>
) }
+12 -7
View File
@@ -1,6 +1,7 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import useIsMounted from 'lib/hooks/useIsMounted';
import InternalTxsList from 'ui/internalTxs/InternalTxsList';
import InternalTxsTable from 'ui/internalTxs/InternalTxsTable';
@@ -8,7 +9,6 @@ import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
import useAddressInternalTxsQuery from './useAddressInternalTxsQuery';
@@ -45,13 +45,18 @@ const AddressInternalTxs = ({ shouldRender = true, isQueryEnabled = true }: Prop
hasActiveFilter={ Boolean(filterValue) }
isLoading={ pagination.isLoading }
/>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'internal-transactions', filterType: 'address', filterValue }}
ml={{ base: 2, lg: 'auto' }}
<CsvExport
type="address_internal_txs"
resourceName="general:address_csv_export_internal_txs"
pathParams={{ hash }}
queryParams={ filterValue ? {
filter_type: 'address',
filter_value: filterValue,
} : undefined }
loadingInitial={ pagination.isLoading }
ml={{ base: 2, lg: 3 }}
/>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
+7 -6
View File
@@ -1,6 +1,7 @@
import { useRouter } from 'next/router';
import React from 'react';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { LOG } from 'stubs/log';
@@ -11,7 +12,6 @@ import LogItem from 'ui/shared/logs/LogItem';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import AddressCsvExportLink from './AddressCsvExportLink';
import useAddressQuery from './utils/useAddressQuery';
type Props = {
@@ -42,12 +42,13 @@ const AddressLogs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
const actionBar = (
<ActionBar mt={ -6 } showShadow justifyContent={{ base: 'space-between', lg: 'end' }}>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'logs' }}
<CsvExport
type="address_logs"
resourceName="general:address_csv_export_logs"
pathParams={{ hash }}
loadingInitial={ pagination.isLoading }
/>
<Pagination ml={{ base: 0, lg: 8 }} { ...pagination }/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
+13 -10
View File
@@ -2,6 +2,7 @@ import { HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
@@ -16,7 +17,6 @@ import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import AddressAdvancedFilterLink from './AddressAdvancedFilterLink';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTokenTransfersLocal from './AddressTokenTransfersLocal';
import useAddressTokenTransfersQuery from './useAddressTokenTransfersQuery';
@@ -116,7 +116,7 @@ const AddressTokenTransfers = ({ shouldRender = true, overloadCount, isQueryEnab
return (
<>
<HStack gap={ 2 }>
<HStack gap={ 3 }>
<TokenTransferFilter
defaultTypeFilters={ localQuery.filters.type }
onTypeFilterChange={ localQuery.onTypeFilterChange }
@@ -126,21 +126,24 @@ const AddressTokenTransfers = ({ shouldRender = true, overloadCount, isQueryEnab
defaultAddressFilter={ localQuery.filters.filter }
isLoading={ localQuery.query.isPlaceholderData }
/>
</HStack>
<HStack gap={{ base: 2, lg: 6 }} ml={{ base: 2, lg: 'auto' }} _empty={{ display: 'none' }}>
<CsvExport
type="address_token_transfers"
resourceName="general:address_csv_export_token_transfers"
pathParams={{ hash }}
queryParams={ localQuery.filters.filter ? {
filter_type: 'address',
filter_value: localQuery.filters.filter,
} : undefined }
loadingInitial={ localQuery.query.isPlaceholderData }
/>
<AddressAdvancedFilterLink
isLoading={ localQuery.query.isPlaceholderData }
address={ hash }
typeFilter={ localQuery.filters.type }
directionFilter={ localQuery.filters.filter }
/>
<AddressCsvExportLink
address={ hash }
params={{ type: 'token-transfers', filterType: 'address', filterValue: localQuery.filters.filter }}
isLoading={ localQuery.query.isPlaceholderData }
/>
</HStack>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...localQuery.query.pagination }/>
<Pagination ml="auto" { ...localQuery.query.pagination }/>
</>
);
}
+11 -6
View File
@@ -3,6 +3,7 @@ import React from 'react';
import type { TokenType } from 'types/api/token';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import { useMultichainContext } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
@@ -15,7 +16,6 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import AddressAdvancedFilterLink from './AddressAdvancedFilterLink';
import AddressCsvExportLink from './AddressCsvExportLink';
import type { Filters } from './useAddressTokenTransfersQuery';
import useAddressTokenTransfersSocket from './useAddressTokenTransfersSocket';
@@ -92,17 +92,22 @@ const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange,
isLoading={ query.isPlaceholderData }
chainConfig={ multichainContext?.chain?.app_config }
/>
<CsvExport
type="address_token_transfers"
resourceName="general:address_csv_export_token_transfers"
pathParams={{ hash: addressHash }}
queryParams={ filters.filter ? {
filter_type: 'address',
filter_value: filters.filter,
} : undefined }
loadingInitial={ isPlaceholderData }
/>
<AddressAdvancedFilterLink
isLoading={ isPlaceholderData }
address={ addressHash }
typeFilter={ filters.type }
directionFilter={ filters.filter }
/>
<AddressCsvExportLink
address={ addressHash }
params={{ type: 'token-transfers', filterType: 'address', filterValue: filters.filter }}
isLoading={ isPlaceholderData }
/>
</HStack>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
+12 -7
View File
@@ -2,6 +2,7 @@ import { HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
@@ -15,7 +16,6 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
import useAddressTxsQuery from './useAddressTxsQuery';
@@ -117,15 +117,20 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
return (
<>
<HStack gap={ 2 }>
<HStack gap={ 3 }>
{ txsLocalFilter }
<CsvExport
type="address_txs"
resourceName="general:address_csv_export_txs"
pathParams={{ hash }}
queryParams={ localQuery.filterValue ? {
filter_type: 'address',
filter_value: localQuery.filterValue,
} : undefined }
loadingInitial={ localQuery.query.pagination.isLoading }
/>
</HStack>
<HStack gap={ 6 }>
<AddressCsvExportLink
address={ hash }
params={{ type: 'transactions', filterType: 'address', filterValue: localQuery.filterValue }}
isLoading={ localQuery.query.pagination.isLoading }
/>
<Pagination { ...localQuery.query.pagination }/>
</HStack>
</>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

-99
View File
@@ -1,99 +0,0 @@
import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import isNeedProxy from 'lib/api/isNeedProxy';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import { Button } from 'toolkit/chakra/button';
import { toaster } from 'toolkit/chakra/toaster';
import { Tooltip } from 'toolkit/chakra/tooltip';
import { downloadBlob } from 'toolkit/utils/file';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
type Props = {
filters: AdvancedFilterParams;
};
const ExportCSV = ({ filters }: Props) => {
const multichainContext = useMultichainContext();
const recaptcha = useReCaptcha();
const [ isLoading, setIsLoading ] = React.useState(false);
const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => {
const url = buildUrl('general:advanced_filter_csv', undefined, filters, undefined, multichainContext?.chain);
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
...(isNeedProxy() && multichainContext?.chain ? { 'x-endpoint': multichainContext.chain.app_config.apis.general?.endpoint } : {}),
},
});
if (!response.ok) {
throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
}
return response;
}, [ filters, multichainContext?.chain ]);
const handleExportCSV = React.useCallback(async() => {
try {
setIsLoading(true);
const response = await recaptcha.fetchProtectedResource(apiFetchFactory);
const blob = await response.blob();
const chainText = multichainContext?.chain ? `${ multichainContext.chain.name.replace(' ', '-') }_` : '';
const fileName = `${ chainText }export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`;
downloadBlob(blob, fileName);
} catch (error) {
toaster.error({
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
});
} finally {
setIsLoading(false);
}
}, [ apiFetchFactory, recaptcha, multichainContext?.chain ]);
const chainConfig = multichainContext?.chain.app_config || config;
if (!chainConfig.services.reCaptchaV2.siteKey) {
return null;
}
return (
<>
<Tooltip
content="This feature is not available due to a reCAPTCHA initialization error. Please contact the project team on Discord to report this issue."
disabled={ !recaptcha.isInitError }
>
<Button
onClick={ handleExportCSV }
variant="outline"
loading={ isLoading }
size="sm"
mr={ 3 }
disabled={ recaptcha.isInitError }
>
Export to CSV
</Button>
</Tooltip>
<ReCaptcha { ...recaptcha } hideWarning/>
</>
);
};
export default ExportCSV;
-132
View File
@@ -1,132 +0,0 @@
import { chakra, Flex } from '@chakra-ui/react';
import React from 'react';
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 config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import isNeedProxy from 'lib/api/isNeedProxy';
import type { ResourceName } from 'lib/api/resources';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { toaster } from 'toolkit/chakra/toaster';
import { downloadBlob } from 'toolkit/utils/file';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import CsvExportFormField from './CsvExportFormField';
interface Props {
hash: string;
resource: ResourceName;
filterType?: CsvExportParams['filterType'] | null;
filterValue?: CsvExportParams['filterValue'] | null;
fileNameTemplate: string;
exportType: CsvExportParams['type'] | undefined;
}
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate, exportType }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
from: dayjs().subtract(1, 'day').format('YYYY-MM-DDTHH:mm'),
to: dayjs().format('YYYY-MM-DDTHH:mm'),
},
});
const { handleSubmit, formState } = formApi;
const recaptcha = useReCaptcha();
const multichainContext = useMultichainContext();
const chainConfig = multichainContext?.chain.app_config || config;
const apiFetchFactory = React.useCallback((data: FormFields) => {
return async(recaptchaToken?: string) => {
const url = buildUrl(resource, { hash } as never, {
from_period: exportType !== 'holders' ? dayjs(data.from).toISOString() : null,
to_period: exportType !== 'holders' ? dayjs(data.to).toISOString() : null,
filter_type: filterType,
filter_value: filterValue,
}, undefined, multichainContext?.chain);
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
...(isNeedProxy() && multichainContext?.chain ? { 'x-endpoint': multichainContext.chain.app_config.apis.general?.endpoint } : {}),
},
});
if (!response.ok) {
throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
}
return response;
};
}, [ resource, hash, exportType, filterType, filterValue, multichainContext?.chain ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const response = await recaptcha.fetchProtectedResource<Response>(apiFetchFactory(data));
const chainText = multichainContext?.chain ? `${ multichainContext.chain.name.replace(' ', '-') }_` : '';
const blob = await response.blob();
const fileName = exportType === 'holders' ?
`${ chainText }${ fileNameTemplate }_${ hash }.csv` :
// eslint-disable-next-line max-len
`${ chainText }${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`;
downloadBlob(blob, fileName);
} catch (error) {
toaster.error({
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
});
}
}, [ recaptcha, apiFetchFactory, multichainContext?.chain, exportType, fileNameTemplate, hash, filterType, filterValue ]);
if (!chainConfig.services.reCaptchaV2.siteKey) {
return (
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
);
}
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
</Flex>
<ReCaptcha { ...recaptcha }/>
<Button
variant="solid"
type="submit"
mt={ 8 }
loading={ formState.isSubmitting }
loadingText="Download"
disabled={ Boolean(formState.errors.from || formState.errors.to || recaptcha.isInitError) }
>
Download
</Button>
</chakra.form>
</FormProvider>
);
};
export default React.memo(CsvExportForm);
-4
View File
@@ -1,4 +0,0 @@
export interface FormFields {
from: string;
to: string;
}
@@ -3,9 +3,9 @@ import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import multichainConfig from 'configs/multichain';
import { MultichainProvider } from 'lib/contexts/multichain';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressTxsFilter from 'ui/address/AddressTxsFilter';
import useAddressInternalTxsQuery from 'ui/address/useAddressInternalTxsQuery';
import InternalTxsList from 'ui/internalTxs/InternalTxsList';
@@ -67,14 +67,19 @@ const MultichainAddressInternalTxs = ({ addressData, isLoading }: Props) => {
chainIds={ chainIds }
ml={ 2 }
/>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'internal-transactions', filterType: 'address', filterValue }}
ml={{ base: 2, lg: 'auto' }}
<CsvExport
type="address_internal_txs"
resourceName="general:address_csv_export_internal_txs"
pathParams={{ hash }}
queryParams={ filterValue ? {
filter_type: 'address',
filter_value: filterValue,
} : undefined }
loadingInitial={ pagination.isLoading }
chainData={ chainData }
ml={ 2 }
/>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
@@ -3,13 +3,13 @@ import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import multichainConfig from 'configs/multichain';
import { MultichainProvider } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import ChainSelect from 'ui/multichain/components/ChainSelect';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
@@ -61,15 +61,16 @@ const MultichainAddressLogs = ({ addressData, isLoading }: Props) => {
mode={ isMobile ? 'compact' : 'default' }
/>
{ (data?.items.length ?? 0) > 0 && (
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'logs' }}
ml={{ base: 2, lg: 'auto' }}
<CsvExport
type="address_logs"
resourceName="general:address_csv_export_logs"
pathParams={{ hash }}
loadingInitial={ pagination.isLoading }
chainData={ chainData }
ml={ 2 }
/>
) }
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
@@ -5,6 +5,7 @@ import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import { MultichainProvider } from 'lib/contexts/multichain';
@@ -14,7 +15,6 @@ import { SocketProvider } from 'lib/socket/context';
import { EmptyState } from 'toolkit/chakra/empty-state';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressAdvancedFilterLink from 'ui/address/AddressAdvancedFilterLink';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressTokenTransfersLocal from 'ui/address/AddressTokenTransfersLocal';
import useAddressTokenTransfersQuery from 'ui/address/useAddressTokenTransfersQuery';
import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery';
@@ -30,6 +30,7 @@ export const ADDRESS_MULTICHAIN_TOKEN_TRANSFERS_TAB_IDS = [ 'token_transfers_cro
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
justifyContent: { base: 'flex-end', lg: 'space-between' },
alignItems: 'center',
ml: { base: 0, lg: 8 },
widthAllocation: 'available' as const,
};
@@ -132,9 +133,17 @@ const MultichainAddressTokenTransfers = ({ addressData, isLoading }: Props) => {
chainConfig={ chainData?.app_config }
/>
{ chainSelect }
{ countersText }
</HStack>
<HStack gap={ 6 }>
<CsvExport
type="address_token_transfers"
resourceName="general:address_csv_export_token_transfers"
pathParams={{ hash }}
queryParams={ transfersQueryLocal.filters.filter ? {
filter_type: 'address',
filter_value: transfersQueryLocal.filters.filter,
} : undefined }
chainData={ chainData }
loadingInitial={ transfersQueryLocal.query.pagination.isLoading }
/>
<AddressAdvancedFilterLink
isLoading={ transfersQueryLocal.query.isPlaceholderData }
address={ hash }
@@ -142,14 +151,9 @@ const MultichainAddressTokenTransfers = ({ addressData, isLoading }: Props) => {
directionFilter={ transfersQueryLocal.filters.filter }
chainData={ chainData }
/>
<AddressCsvExportLink
address={ hash }
params={{ type: 'token-transfers', filterType: 'address', filterValue: transfersQueryLocal.filters.filter }}
isLoading={ transfersQueryLocal.query.pagination.isLoading }
chainData={ chainData }
/>
<Pagination { ...transfersQueryLocal.query.pagination }/>
</HStack>
{ countersText }
<Pagination ml="auto" { ...transfersQueryLocal.query.pagination }/>
</>
);
}
+13 -9
View File
@@ -5,6 +5,7 @@ import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import { MultichainProvider } from 'lib/contexts/multichain';
@@ -13,7 +14,6 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import { SocketProvider } from 'lib/socket/context';
import { EmptyState } from 'toolkit/chakra/empty-state';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressTxsFilter from 'ui/address/AddressTxsFilter';
import useAddressTxsQuery from 'ui/address/useAddressTxsQuery';
import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery';
@@ -35,6 +35,7 @@ const TAB_LIST_PROPS = {
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
justifyContent: { base: 'flex-end', lg: 'space-between' },
alignItems: 'center',
ml: { base: 0, lg: 8 },
widthAllocation: 'available' as const,
};
@@ -119,17 +120,20 @@ const MultichainAddressTxs = ({ addressData, isLoading }: Props) => {
<HStack gap={ 2 }>
{ txsLocalFilter }
{ chainSelect }
{ countersText }
</HStack>
<HStack gap={ 6 }>
<AddressCsvExportLink
address={ hash }
params={{ type: 'transactions', filterType: 'address', filterValue: txsQueryLocal.filterValue }}
isLoading={ txsQueryLocal.query.pagination.isLoading }
<CsvExport
type="address_txs"
resourceName="general:address_csv_export_txs"
pathParams={{ hash }}
queryParams={ txsQueryLocal.filterValue ? {
filter_type: 'address',
filter_value: txsQueryLocal.filterValue,
} : undefined }
chainData={ chainData }
loadingInitial={ txsQueryLocal.query.pagination.isLoading }
/>
<Pagination { ...txsQueryLocal.query.pagination }/>
</HStack>
{ countersText }
<Pagination ml="auto" { ...txsQueryLocal.query.pagination }/>
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -25,7 +25,7 @@ const ListCounterText = ({ isLoading, value, type }: Props) => {
loading={ isInitialLoading }
textStyle={{ base: 'md', lg: 'sm' }}
color="text.secondary"
ml={{ base: 0, lg: 4 }}
ml={{ base: 0, lg: 6 }}
mr={{ base: 0, lg: 8 }}
mb={{ base: 4, lg: 0 }}
/>
+11 -2
View File
@@ -12,6 +12,7 @@ import React from 'react';
import type { AdvancedFilterParams } from 'types/api/advancedFilter';
import { ADVANCED_FILTER_AGES, ADVANCED_FILTER_ADDRESS_RELATION } from 'types/api/advancedFilter';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import useApiQuery from 'lib/api/useApiQuery';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { useMultichainContext } from 'lib/contexts/multichain';
@@ -28,7 +29,6 @@ import { Tag } from 'toolkit/chakra/tag';
import ColumnsButton from 'ui/advancedFilter/ColumnsButton';
import type { ColumnsIds } from 'ui/advancedFilter/constants';
import { getAdvancedFilterTypes, 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';
@@ -230,8 +230,17 @@ const AdvancedFilter = () => {
const actionBar = (
<ActionBar mt={ -6 }>
<ExportCSV filters={ filters }/>
<ColumnsButton columns={ columns } onChange={ setColumns }/>
<CsvExport
type="advanced_filters"
resourceName="general:advanced_filter_csv"
queryParams={ filters }
extraParams={{
created_at: dayjs().toISOString(),
}}
periodFilter={ false }
ml={ 3 }
/>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
+2
View File
@@ -31,6 +31,7 @@ import RatingShowcase from 'ui/showcases/Rating';
import SelectShowcase from 'ui/showcases/Select';
import SkeletonShowcase from 'ui/showcases/Skeleton';
import SpinnerShowcase from 'ui/showcases/Spinner';
import StatusShowcase from 'ui/showcases/Status';
import SwitchShowcase from 'ui/showcases/Switch';
import TableShowcase from 'ui/showcases/Table';
import TabsShowcase from 'ui/showcases/Tabs';
@@ -67,6 +68,7 @@ const tabs = [
{ label: 'Select', value: 'select', component: <SelectShowcase/> },
{ label: 'Skeleton', value: 'skeleton', component: <SkeletonShowcase/> },
{ label: 'Spinner', value: 'spinner', component: <SpinnerShowcase/> },
{ label: 'Status', value: 'status', component: <StatusShowcase/> },
{ label: 'Switch', value: 'switch', component: <SwitchShowcase/> },
{ label: 'Table', value: 'table', component: <TableShowcase/> },
{ label: 'Tabs', value: 'tabs', component: <TabsShowcase/> },
-35
View File
@@ -1,35 +0,0 @@
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import { test, expect } from 'playwright/lib';
import CsvExport from './CsvExport';
test('base view +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
},
};
await mockApiResponse('general:address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('general:config_csv_export', { limit: 42123 });
const component = await render(<CsvExport/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test('token holders', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'holders' },
},
};
await mockApiResponse('general:address', addressMock.token, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('general:token', tokenMock.tokenInfo, { pathParams: { hash: addressMock.hash } });
const component = await render(<CsvExport/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
-208
View File
@@ -1,208 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
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';
import { useMultichainContext } from 'lib/contexts/multichain';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import { ContentLoader } from 'toolkit/components/loaders/ContentLoader';
import { nbsp } from 'toolkit/utils/htmlEntities';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import ChainIcon from 'ui/shared/externalChains/ChainIcon';
import PageTitle from 'ui/shared/Page/PageTitle';
interface ExportTypeEntity {
text: string;
resource: ResourceName;
fileNameTemplate: string;
filterType?: CsvExportParams['filterType'];
filterValues?: Readonly<Array<CsvExportParams['filterValue']>>;
}
const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
transactions: {
text: 'transactions',
resource: 'general:address_csv_export_txs',
fileNameTemplate: 'transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'internal-transactions': {
text: 'internal transactions',
resource: 'general:address_csv_export_internal_txs',
fileNameTemplate: 'internal_transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'token-transfers': {
text: 'token transfers',
resource: 'general:address_csv_export_token_transfers',
fileNameTemplate: 'token_transfers',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
logs: {
text: 'logs',
resource: 'general:address_csv_export_logs',
fileNameTemplate: 'logs',
filterType: 'topic',
},
holders: {
text: 'holders',
resource: 'general:token_csv_export_holders',
fileNameTemplate: 'holders',
},
'epoch-rewards': {
text: 'epoch rewards',
resource: 'general:address_csv_export_celo_election_rewards',
fileNameTemplate: 'epoch_rewards',
},
};
const isCorrectExportType = (type: string): type is CsvExportParams['type'] => Object.keys(EXPORT_TYPES).includes(type);
const CsvExport = () => {
const router = useRouter();
const isMobile = useIsMobile();
const multichainContext = useMultichainContext();
const addressHash = router.query.address?.toString() || '';
const exportTypeParam = router.query.type?.toString() || '';
const exportType = isCorrectExportType(exportTypeParam) ? EXPORT_TYPES[exportTypeParam] : null;
const filterTypeFromQuery = router.query.filterType?.toString() || null;
const filterValueFromQuery = router.query.filterValue?.toString();
const addressQuery = useApiQuery('general:address', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
},
});
const tokenQuery = useApiQuery('general:token', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash) && exportTypeParam === 'holders',
},
});
const configQuery = useApiQuery('general:config_csv_export', {
queryOptions: {
enabled: Boolean(addressHash),
},
});
const isLoading = addressQuery.isPending || configQuery.isPending || (exportTypeParam === 'holders' && tokenQuery.isPending);
throwOnAbsentParamError(addressHash);
throwOnAbsentParamError(exportType);
if (!exportType) {
return null;
}
const filterType = filterTypeFromQuery === exportType.filterType ? filterTypeFromQuery : null;
const filterValue = (() => {
if (!filterType || !filterValueFromQuery) {
return null;
}
if (exportType.filterValues && !exportType.filterValues?.includes(filterValueFromQuery)) {
return null;
}
return filterValueFromQuery;
})();
const content = (() => {
throwOnResourceLoadError(addressQuery);
if (isLoading) {
return <ContentLoader/>;
}
return (
<CsvExportForm
hash={ addressHash }
resource={ exportType.resource }
exportType={ isCorrectExportType(exportTypeParam) ? exportTypeParam : undefined }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ exportType.fileNameTemplate }
/>
);
})();
const description = (() => {
if (isLoading) {
return null;
}
const chainInfo = multichainContext?.chain ? (
<Flex display="inline-flex" alignItems="center" columnGap={ 2 }>
<span>on</span>
<ChainIcon data={ multichainContext.chain }/>
<span>{ multichainContext.chain.app_config.chain.name }</span>
</Flex>
) : null;
const limit = (configQuery.data?.limit || 10_000).toLocaleString(undefined, { maximumFractionDigits: 3, notation: 'compact' });
if (exportTypeParam === 'holders' && tokenQuery.data) {
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="fit-content"
maxW={{ base: '100%', lg: '400px' }}
noCopy
noSymbol
/>
{ chainInfo }
<span> to CSV file. </span>
<span>Exports are limited to the top { limit } holders by amount held.</span>
</Flex>
);
}
if (!addressQuery.data) {
return null;
}
return (
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { exportType.text } for address </span>
<AddressEntity
address={ addressQuery.data }
truncation={ isMobile ? 'constant' : 'dynamic' }
noCopy
/>
<span>{ nbsp }</span>
{ filterType && filterValue && <span>with applied filter by { filterType } ({ filterValue })</span> }
{ chainInfo }
<span> to CSV file. </span>
<span>Exports are limited to the last { limit } { exportType.text }.</span>
</Flex>
);
})();
return (
<>
<PageTitle title="Export data to CSV file"/>
{ description }
{ content }
</>
);
};
export default CsvExport;
+10 -7
View File
@@ -8,6 +8,7 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import type { TokenInfo } from 'types/api/token';
import type { PaginationParams } from 'ui/shared/pagination/types';
import CsvExport from 'client/features/csv-export/components/CsvExport';
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
@@ -25,7 +26,6 @@ import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import Address3rdPartyWidgets from 'ui/address/Address3rdPartyWidgets';
import useAddress3rdPartyWidgets from 'ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets';
import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils';
import TextAd from 'ui/shared/ad/TextAd';
import IconSvg from 'ui/shared/IconSvg';
@@ -44,7 +44,6 @@ export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
alignItems: 'center',
columnGap: 4,
};
const TokenPageContent = () => {
@@ -262,14 +261,18 @@ const TokenPageContent = () => {
<TokenAdvancedFilterLink token={ tokenQuery.data } ml={ 6 }/>
) }
{ tab === 'holders' && (
<AddressCsvExportLink
address={ hashString }
params={{ type: 'holders' }}
isLoading={ pagination?.isLoading }
<CsvExport
type="token_holders"
resourceName="general:token_csv_export_holders"
pathParams={{ hash: hashString }}
queryParams={{ from_period: null, to_period: null }}
extraParams={{ token_name: tokenQuery.data?.name || 'Unknown token' }}
periodFilter={ false }
loadingInitial={ pagination?.isLoading }
ml={ 6 }
/>
) }
{ pagination?.isVisible && <Pagination { ...pagination }/> }
{ pagination?.isVisible && <Pagination ml={ 6 } { ...pagination }/> }
</>
);
}, [ hashString, isMobile, pagination, tab, tokenQuery.data ]);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

+6
View File
@@ -53,6 +53,12 @@ const IconButtonShowcase = () => {
<IconButton size="md" variant="icon_background" selected disabled>
<IconSvg name="heart_filled"/>
</IconButton>
<IconButton size="md" variant="icon_background" loading>
<IconSvg name="heart_filled"/>
</IconButton>
<IconButton size="md" variant="icon_background" loadingSkeleton>
<IconSvg name="heart_filled"/>
</IconButton>
</Sample>
<Sample label="variant: dropdown">
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { Status } from 'toolkit/chakra/status';
import { Section, Container, SectionHeader, SamplesStack, Sample } from './parts';
const StatusShowcase = () => {
return (
<Container value="status">
<Section>
<SectionHeader>Variants</SectionHeader>
<SamplesStack>
<Sample label="variant: default">
<Status/>
<Status borderColor="popover.bg"/>
</Sample>
</SamplesStack>
</Section>
<Section>
<SectionHeader>Size</SectionHeader>
<SamplesStack>
<Sample label="size: xs">
<Status size="xs"/>
</Sample>
<Sample label="size: sm">
<Status size="sm"/>
</Sample>
<Sample label="size: md">
<Status size="md"/>
</Sample>
</SamplesStack>
</Section>
</Container>
);
};
export default React.memo(StatusShowcase);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+35
View File
@@ -1,5 +1,8 @@
import React from 'react';
import * as csvExportStorageMock from 'client/features/csv-export/mocks/storage';
import type { StorageItem } from 'client/features/csv-export/utils/storage';
import { STORAGE_KEY } from 'client/features/csv-export/utils/storage';
import * as statsMock from 'mocks/stats/index';
import { test, expect } from 'playwright/lib';
@@ -73,3 +76,35 @@ test('with Get gas button', async({ render, mockApiResponse, mockEnvs, mockAsset
const component = await render(<TopBar/>);
await expect(component).toHaveScreenshot();
});
test('with csv downloads', async({ render, mockApiResponse, page, injectMetaMaskProvider }) => {
await injectMetaMaskProvider();
await mockApiResponse('general:stats', statsMock.base);
const downloadsStorateValue: Array<StorageItem> = [
csvExportStorageMock.itemCompleted,
csvExportStorageMock.itemPending,
csvExportStorageMock.itemExpired,
csvExportStorageMock.itemFailed,
];
await page.evaluate(({ name, value }) => {
window.localStorage.setItem(name, value);
}, { name: STORAGE_KEY, value: JSON.stringify(downloadsStorateValue) });
await mockApiResponse('general:csv_exports_item', csvExportStorageMock.itemPending, {
pathParams: {
id: csvExportStorageMock.itemPending.request_id,
},
});
await mockApiResponse('general:csv_exports_item', { status: 404 } as never, {
pathParams: {
id: csvExportStorageMock.itemExpired.request_id,
},
status: 404,
});
const component = await render(<TopBar/>);
await component.getByLabel('Open list of downloads').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 450 } });
});
+7 -3
View File
@@ -1,6 +1,7 @@
import { Flex, Separator, Box, HStack } from '@chakra-ui/react';
import { Flex, Box, HStack } from '@chakra-ui/react';
import React from 'react';
import CsvExportDownloads from 'client/features/csv-export/components/downloads/CsvExportDownloads';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
@@ -54,7 +55,7 @@ const TopBar = () => {
</HStack>
<HStack
alignItems="center"
separator={ <Separator mx={{ base: 2, lg: 3 }} height={ 4 }/> }
gap={ 3 }
>
{ (hasAddChainButton || hasDeFiDropdown) && (
<HStack>
@@ -62,7 +63,10 @@ const TopBar = () => {
{ hasDeFiDropdown && <DeFiDropdown/> }
</HStack>
) }
<Settings/>
<HStack gap={ 0 }>
<CsvExportDownloads/>
<Settings/>
</HStack>
</HStack>
</Flex>
</Box>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Some files were not shown because too many files have changed in this diff Show More