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

This commit is contained in:
Bartłomiej Głownia 2023-11-29 13:28:31 +01:00 committed by GitHub
parent 5ee1748495
commit 7588d0cd11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 208 additions and 163 deletions

View File

@ -16,15 +16,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

@ -1,6 +1,9 @@
{ {
"Date from": "Date from", "Date from": "Date from",
"Date from cannot be greater than date to": "Date from cannot be greater than date to",
"Date from cannot be in the future": "Date from cannot be in the future",
"Date to": "Date to", "Date to": "Date to",
"Date to cannot be in the future": "Date to cannot be in the future",
"Download": "Download", "Download": "Download",
"Download all to .csv file": "Download all to .csv file", "Download all to .csv file": "Download all to .csv file",
"Download has been started": "Download has been started", "Download has been started": "Download has been started",
@ -13,6 +16,8 @@
"Still in progress": "Still in progress", "Still in progress": "Still in progress",
"The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC{{offset}}.": "The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC{{offset}}.", "The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC{{offset}}.": "The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC{{offset}}.",
"Try again later": "Try again later", "Try again later": "Try again later",
"You need to provide a date from": "You need to provide a date from",
"You need to select an asset": "You need to select an asset",
"You will be notified here when your file is ready.": "You will be notified here when your file is ready.", "You will be notified here when your file is ready.": "You will be notified here when your file is ready.",
"Your file is ready": "Your file is ready" "Your file is ready": "Your file is ready"
} }

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 { localLoggerFactory } from '@vegaprotocol/logger'; import { localLoggerFactory } from '@vegaprotocol/logger';
import { useLedgerDownloadFile } from './ledger-download-store'; import { useLedgerDownloadFile } from './ledger-download-store';
@ -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);
@ -72,117 +72,106 @@ interface Props {
export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => { export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
const t = useT(); const t = useT();
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({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
});
const startDownload = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const title = t(
'Downloading for {{asset}} from {{startDate}} till {{endDate}}',
{
asset: assets[assetId],
startDate: format(new Date(dateFrom), 'dd MMMM yyyy HH:mm'),
endDate: 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,
}); });
}, 1000 * 30);
try { const dateTimeFormatter = getDateTimeFormat();
updateDownloadQueue(downloadStoreItem); const title = t(
const resp = await fetch(link); 'Downloading for {{asset}} from {{startDate}} till {{endDate}}',
if (!resp?.ok) { {
if (resp?.status === 429) { asset: assets[formValues.assetId],
throw new Error('Too many requests. Try again later.'); startDate: dateTimeFormatter.format(new Date(formValues.dateFrom)),
endDate: dateTimeFormatter.format(
new Date(formValues.dateTo || Date.now())
),
} }
throw new Error('Download of ledger entries failed'); );
const downloadStoreItem = {
title,
link,
isChanged: true,
};
if (hasItem(link)) {
updateDownloadQueue(downloadStoreItem);
return;
} }
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, t, 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;
@ -191,49 +180,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{{offset}}.', 'The downloaded file uses the UTC time zone for all listed times. Your time zone is UTC{{offset}}.',
{ offset: toHoursAndMinutes(offset) } { offset: toHoursAndMinutes(offset) }
)} )}
</p> </p>
)} ) : null}
</form> </form>
); );
}; };