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]>
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -302,6 +302,7 @@ export default tseslint.config(
|
||||
'/types/',
|
||||
[ '/^nextjs/' ],
|
||||
[
|
||||
'/^client/',
|
||||
'/^configs/',
|
||||
'/^data/',
|
||||
'/^deploy/',
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
@@ -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 */
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type NextJsQueryParam = string | Array<string> | undefined;
|
||||
@@ -59,6 +59,11 @@ export const chainA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
reCaptchaV2: {
|
||||
siteKey: 'xxx',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ClusterChainConfig;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
| "delete"
|
||||
| "docs"
|
||||
| "dots"
|
||||
| "download"
|
||||
| "edit"
|
||||
| "email"
|
||||
| "ENS"
|
||||
|
||||
@@ -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 }/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}' } },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -7,10 +7,6 @@ export interface BackendVersionConfig {
|
||||
backend_version: string;
|
||||
}
|
||||
|
||||
export interface CsvExportConfig {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface CeloConfig {
|
||||
l2_migration_block: number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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 }
|
||||
/>
|
||||
) }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 }/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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 }/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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/> },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 ]);
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |