mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
* 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: Hanzo Dev <[email protected]>
282 lines
9.7 KiB
TypeScript
282 lines
9.7 KiB
TypeScript
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);
|