feat(trading): refactor ledger export form validation (#5379)

This commit is contained in:
Bartłomiej Głownia 2023-11-29 16:27:00 +01:00 committed by GitHub
parent 4e2b0d1b1d
commit 52ab0562b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 198 additions and 160 deletions

View File

@ -15,15 +15,12 @@ export const LedgerContainer = () => {
}); });
const assets = (data?.party?.accountsConnection?.edges ?? []) const assets = (data?.party?.accountsConnection?.edges ?? [])
.map<PartyAssetFieldsFragment>( .map((item) => item?.node?.asset)
(item) => item?.node?.asset ?? ({} as PartyAssetFieldsFragment) .filter((asset): asset is PartyAssetFieldsFragment => !!asset?.id)
) .reduce(
.reduce((aggr, item) => { (aggr, item) => Object.assign(aggr, { [item.id]: item.symbol }),
if ('id' in item && 'symbol' in item) { {} as Record<string, string>
aggr[item.id as string] = item.symbol as string; );
}
return aggr;
}, {} as Record<string, string>);
if (!pubKey) { if (!pubKey) {
return ( return (

View File

@ -278,18 +278,4 @@ describe('createDownloadUrl', () => {
)}&dateRange.endTimestamp=${toNanoSeconds(dateTo)}` )}&dateRange.endTimestamp=${toNanoSeconds(dateTo)}`
); );
}); });
it('should throw if invalid args are provided', () => {
// invalid url
expect(() => {
// @ts-ignore override z.infer type
createDownloadUrl({ ...args, protohost: 'foo' });
}).toThrow();
// invalid partyId
expect(() => {
// @ts-ignore override z.infer type
createDownloadUrl({ ...args, partyId: 'z'.repeat(64) });
}).toThrow();
});
}); });

View File

@ -1,18 +1,18 @@
import { useRef, useState } from 'react'; import { useRef, useCallback } from 'react';
import { format, subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { Controller, useForm } from 'react-hook-form';
import { import {
InputError,
Intent, Intent,
Loader,
TradingButton, TradingButton,
TradingFormGroup, TradingFormGroup,
TradingInput, TradingInput,
TradingSelect, TradingSelect,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { z } from 'zod';
import { import {
formatForInput, formatForInput,
getDateTimeFormat,
toNanoSeconds, toNanoSeconds,
VEGA_ID_REGEX,
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { localLoggerFactory } from '@vegaprotocol/logger'; import { localLoggerFactory } from '@vegaprotocol/logger';
@ -35,18 +35,18 @@ const getProtoHost = (vegaurl: string) => {
return `${loc.protocol}//${loc.host}`; return `${loc.protocol}//${loc.host}`;
}; };
const downloadSchema = z.object({ type LedgerFormValues = {
protohost: z.string().url().nonempty(), assetId: string;
partyId: z.string().regex(VEGA_ID_REGEX).nonempty(), dateFrom: string;
assetId: z.string().regex(VEGA_ID_REGEX).nonempty(), dateTo?: string;
dateFrom: z.string().nonempty(), };
dateTo: z.string().optional(),
});
export const createDownloadUrl = (args: z.infer<typeof downloadSchema>) => {
// check args from form inputs
downloadSchema.parse(args);
export const createDownloadUrl = (
args: LedgerFormValues & {
partyId: string;
protohost: string;
}
) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('partyId', args.partyId); params.append('partyId', args.partyId);
params.append('assetId', args.assetId); params.append('assetId', args.assetId);
@ -71,114 +71,101 @@ interface Props {
export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => { export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
const now = useRef(new Date()); const now = useRef(new Date());
const [dateFrom, setDateFrom] = useState(() => { const { control, handleSubmit, watch } = useForm<LedgerFormValues>({
return formatForInput(subDays(now.current, 7)); defaultValues: {
dateFrom: formatForInput(subDays(now.current, 7)),
dateTo: '',
assetId: Object.keys(assets)[0],
},
}); });
const [dateTo, setDateTo] = useState(''); const dateTo = watch('dateTo');
const maxFromDate = formatForInput(new Date(dateTo || now.current)); const maxFromDate = formatForInput(new Date(dateTo || now.current));
const maxToDate = formatForInput(now.current); const maxToDate = formatForInput(now.current);
const [assetId, setAssetId] = useState(Object.keys(assets)[0]);
const protohost = getProtoHost(vegaUrl); const protohost = getProtoHost(vegaUrl);
const disabled = Boolean(!assetId);
const hasItem = useLedgerDownloadFile((store) => store.hasItem); const hasItem = useLedgerDownloadFile((store) => store.hasItem);
const updateDownloadQueue = useLedgerDownloadFile( const updateDownloadQueue = useLedgerDownloadFile(
(store) => store.updateQueue (store) => store.updateQueue
); );
const assetDropDown = ( const startDownload = useCallback(
<TradingSelect async (formValues: LedgerFormValues) => {
id="select-ledger-asset" const link = createDownloadUrl({
value={assetId} protohost,
onChange={(e) => { partyId,
setAssetId(e.target.value); ...formValues,
}} });
className="w-full"
data-testid="select-ledger-asset"
>
{Object.keys(assets).map((assetKey) => (
<option key={assetKey} value={assetKey}>
{assets[assetKey]}
</option>
))}
</TradingSelect>
);
const link = createDownloadUrl({ const dateTimeFormatter = getDateTimeFormat();
protohost, const title = t('Downloading for %s from %s till %s', [
partyId, assets[formValues.assetId],
assetId, dateTimeFormatter.format(new Date(formValues.dateFrom)),
dateFrom, dateTimeFormatter.format(new Date(formValues.dateTo || Date.now())),
dateTo, ]);
});
const startDownload = async (event: React.FormEvent<HTMLFormElement>) => { const downloadStoreItem = {
event.preventDefault(); title,
link,
const title = t('Downloading for %s from %s till %s', [
assets[assetId],
format(new Date(dateFrom), 'dd MMMM yyyy HH:mm'),
format(new Date(dateTo || Date.now()), 'dd MMMM yyyy HH:mm'),
]);
const downloadStoreItem = {
title,
link,
isChanged: true,
};
if (hasItem(link)) {
updateDownloadQueue(downloadStoreItem);
return;
}
const ts = setTimeout(() => {
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Warning,
isDelayed: true,
isChanged: true, isChanged: true,
}); };
}, 1000 * 30); if (hasItem(link)) {
updateDownloadQueue(downloadStoreItem);
try { return;
updateDownloadQueue(downloadStoreItem);
const resp = await fetch(link);
if (!resp?.ok) {
if (resp?.status === 429) {
throw new Error('Too many requests. Try again later.');
}
throw new Error('Download of ledger entries failed');
} }
const { headers } = resp; const ts = setTimeout(() => {
const nameHeader = headers.get('content-disposition');
const filename = nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME;
updateDownloadQueue({
...downloadStoreItem,
filename,
});
const blob = await resp.blob();
if (blob) {
updateDownloadQueue({ updateDownloadQueue({
...downloadStoreItem, ...downloadStoreItem,
blob, intent: Intent.Warning,
isDownloaded: true, isDelayed: true,
isChanged: true, isChanged: true,
intent: Intent.Success,
}); });
}, 1000 * 30);
try {
updateDownloadQueue(downloadStoreItem);
const resp = await fetch(link);
if (!resp?.ok) {
if (resp?.status === 429) {
throw new Error('Too many requests. Try again later.');
}
throw new Error('Download of ledger entries failed');
}
const { headers } = resp;
const nameHeader = headers.get('content-disposition');
const filename =
nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME;
updateDownloadQueue({
...downloadStoreItem,
filename,
});
const blob = await resp.blob();
if (blob) {
updateDownloadQueue({
...downloadStoreItem,
blob,
isDownloaded: true,
isChanged: true,
intent: Intent.Success,
});
}
} catch (err) {
localLoggerFactory({ application: 'ledger' }).error(
'Download file',
err
);
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Danger,
isError: true,
isChanged: true,
errorMessage: (err as Error).message || undefined,
});
} finally {
clearTimeout(ts);
} }
} catch (err) { },
localLoggerFactory({ application: 'ledger' }).error('Download file', err); [assets, hasItem, partyId, protohost, updateDownloadQueue]
updateDownloadQueue({ );
...downloadStoreItem,
intent: Intent.Danger,
isError: true,
isChanged: true,
errorMessage: (err as Error).message || undefined,
});
} finally {
clearTimeout(ts);
}
};
if (!protohost || Object.keys(assets).length === 0) { if (!protohost || Object.keys(assets).length === 0) {
return null; return null;
@ -187,49 +174,117 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
const offset = new Date().getTimezoneOffset(); const offset = new Date().getTimezoneOffset();
return ( return (
<form onSubmit={startDownload} className="p-4 w-[350px]"> <form
onSubmit={handleSubmit(startDownload)}
className="p-4 w-[350px]"
noValidate
>
<h2 className="mb-4">{t('Export ledger entries')}</h2> <h2 className="mb-4">{t('Export ledger entries')}</h2>
<TradingFormGroup label={t('Select asset')} labelFor="asset"> <Controller
{assetDropDown} name="assetId"
</TradingFormGroup> control={control}
<TradingFormGroup label={t('Date from')} labelFor="date-from"> rules={{
<TradingInput required: t('You need to select an asset'),
type="datetime-local" }}
data-testid="date-from" render={({ field, fieldState }) => (
id="date-from" <div className="mb-2">
value={dateFrom} <TradingFormGroup
onChange={(e) => setDateFrom(e.target.value)} label={t('Select asset')}
max={maxFromDate} labelFor="asset"
/> compact
</TradingFormGroup> >
<TradingFormGroup label={t('Date to')} labelFor="date-to"> <TradingSelect
<TradingInput {...field}
type="datetime-local" id="select-ledger-asset"
data-testid="date-to" className="w-full"
id="date-to" data-testid="select-ledger-asset"
value={dateTo} >
onChange={(e) => setDateTo(e.target.value)} {Object.keys(assets).map((assetKey) => (
max={maxToDate} <option key={assetKey} value={assetKey}>
/> {assets[assetKey]}
</TradingFormGroup> </option>
))}
</TradingSelect>
</TradingFormGroup>
{fieldState.error && (
<InputError>{fieldState.error.message}</InputError>
)}
</div>
)}
/>
<Controller
name="dateFrom"
control={control}
rules={{
required: t('You need to provide a date from'),
max: {
value: maxFromDate,
message: dateTo
? t('Date from cannot be greater than date to')
: t('Date from cannot be in the future'),
},
deps: ['dateTo'],
}}
render={({ field, fieldState }) => (
<div className="mb-2">
<TradingFormGroup
label={t('Date from')}
labelFor="date-from"
compact
>
<TradingInput
{...field}
type="datetime-local"
data-testid="date-from"
id="date-from"
max={maxFromDate}
/>
</TradingFormGroup>
{fieldState.error && (
<InputError>{fieldState.error.message}</InputError>
)}
</div>
)}
/>
<Controller
name="dateTo"
control={control}
rules={{
max: {
value: maxToDate,
message: t('Date to cannot be in the future'),
},
}}
render={({ field, fieldState }) => (
<div className="mb-2">
<TradingFormGroup label={t('Date to')} labelFor="date-to" compact>
<TradingInput
{...field}
type="datetime-local"
data-testid="date-to"
id="date-to"
max={maxToDate}
/>
</TradingFormGroup>
{fieldState.error && (
<InputError>{fieldState.error.message}</InputError>
)}
</div>
)}
/>
<div className="relative text-sm" title={t('Download all to .csv file')}> <div className="relative text-sm" title={t('Download all to .csv file')}>
<TradingButton <TradingButton fill type="submit" data-testid="ledger-download-button">
fill
disabled={disabled}
type="submit"
data-testid="ledger-download-button"
>
{t('Download')} {t('Download')}
</TradingButton> </TradingButton>
</div> </div>
{offset && ( {offset ? (
<p className="text-xs text-neutral-400 mt-1"> <p className="text-xs text-neutral-400 mt-1">
{t( {t(
'The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC%s.', 'The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC%s.',
[toHoursAndMinutes(offset)] [toHoursAndMinutes(offset)]
)} )}
</p> </p>
)} ) : null}
</form> </form>
); );
}; };