feat(trading): refactor ledger export form validation (#5362)
This commit is contained in:
parent
5ee1748495
commit
7588d0cd11
@ -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 (
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user