chore(ledger): 4895 refactor ledger csv extract (#5053)

This commit is contained in:
Maciek 2023-10-17 17:15:38 +02:00 committed by GitHub
parent a0844d41bf
commit 9dc9588a14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 286 additions and 53 deletions

View File

@ -6,7 +6,7 @@ import { TelemetryApproval } from './telemetry-approval';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useOnboardingStore } from '../welcome-dialog/use-get-onboarding-step'; import { useOnboardingStore } from '../welcome-dialog/use-get-onboarding-step';
const TELEMETRY_APPROVAL_TOAST_ID = 'telemetry_tost_id'; const TELEMETRY_APPROVAL_TOAST_ID = 'telemetry_toast_id';
export const Telemetry = () => { export const Telemetry = () => {
const onboardingDissmissed = useOnboardingStore((store) => store.dismissed); const onboardingDissmissed = useOnboardingStore((store) => store.dismissed);

View File

@ -7,6 +7,7 @@ import {
useEthTransactionUpdater, useEthTransactionUpdater,
useEthWithdrawApprovalsManager, useEthWithdrawApprovalsManager,
} from '@vegaprotocol/web3'; } from '@vegaprotocol/web3';
import { useLedgerDownloadManager } from '@vegaprotocol/ledger';
export const TransactionHandlers = () => { export const TransactionHandlers = () => {
useVegaTransactionManager(); useVegaTransactionManager();
@ -14,5 +15,6 @@ export const TransactionHandlers = () => {
useEthTransactionManager(); useEthTransactionManager();
useEthTransactionUpdater(); useEthTransactionUpdater();
useEthWithdrawApprovalsManager(); useEthWithdrawApprovalsManager();
useLedgerDownloadManager();
return null; return null;
}; };

View File

@ -1 +1,2 @@
export * from './lib/ledger-export-form'; export * from './lib/ledger-export-form';
export * from './lib/ledger-download-store';

View File

@ -0,0 +1,142 @@
import { create } from 'zustand';
import type { ReactNode } from 'react';
import { useCallback, useEffect } from 'react';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { useToasts, Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
import { subscribeWithSelector } from 'zustand/middleware';
type DownloadSettings = {
title: string;
link: string;
filename?: string;
isDownloaded?: boolean;
isChanged?: boolean;
isError?: boolean;
errorMessage?: string;
isDelayed?: boolean;
intent?: Intent;
blob?: Blob;
};
export type LedgerDownloadFileStore = {
queue: DownloadSettings[];
hasItem: (link: string) => boolean;
removeItem: (link: string) => void;
updateQueue: (item: DownloadSettings) => void;
};
export const useLedgerDownloadFile = create<LedgerDownloadFileStore>()(
subscribeWithSelector((set, get) => ({
queue: [],
hasItem: (link: string) =>
get().queue.findIndex((item) => item.link === link) > -1,
removeItem: (link: string) => {
const queue = get().queue;
const index = queue.findIndex((item) => item.link === link);
if (index > -1) {
queue.splice(index, 1);
set({ queue: [...queue] });
}
},
updateQueue: (newitem: DownloadSettings) => {
const queue = get().queue;
const index = queue.findIndex((item) => item.link === newitem.link);
if (index > -1) {
queue[index] = { ...queue[index], ...newitem };
set({ queue: [...queue] });
} else {
set({ queue: [newitem, ...queue] });
}
},
}))
);
const ErrorContent = ({ message }: { message?: string }) => (
<>
<h4 className="mb-1 text-sm">{t('Something went wrong')}</h4>
<p>{message || t('Try again later')}</p>
</>
);
const InfoContent = ({ progress = false }) => (
<>
<p>{t('Please note this can take several minutes.')}</p>
<p>{t('You will be notified here when your file is ready.')}</p>
<h4 className="my-2">
{progress ? t('Still in progress') : t('Download has been started')}
</h4>
</>
);
export const useLedgerDownloadManager = () => {
const queue = useLedgerDownloadFile((store) => store.queue);
const updateQueue = useLedgerDownloadFile((store) => store.updateQueue);
const removeItem = useLedgerDownloadFile((store) => store.removeItem);
const [setToast, updateToast, hasToast, removeToast] = useToasts((store) => [
store.setToast,
store.update,
store.hasToast,
store.remove,
]);
const onDownloadClose = useCallback(
(id: string) => {
removeToast(id);
removeItem(id);
},
[removeToast, removeItem]
);
const createToast = (item: DownloadSettings) => {
let content: ReactNode;
switch (true) {
case item.isError:
content = <ErrorContent message={item.errorMessage} />;
break;
case Boolean(item.blob):
content = (
<>
<h4 className="mb-1 text-sm">{t('Your file is ready')}</h4>
<a
onClick={() => onDownloadClose(item.link)}
href={URL.createObjectURL(item.blob as Blob)}
download={item.filename}
className="underline"
>
{t('Get file here')}
</a>
</>
);
break;
default:
content = <InfoContent progress={item.isDelayed} />;
}
const toast: Toast = {
id: item.link,
intent: item.intent || Intent.Primary,
content: (
<>
<h3 className="mb-1 text-md uppercase">{item.title}</h3>
{content}
</>
),
onClose: () => onDownloadClose(item.link),
loader: !item.isDownloaded && !item.isError,
};
if (hasToast(toast.id)) {
updateToast(toast.id, toast);
} else {
setToast(toast);
}
};
useEffect(() => {
queue.forEach((item) => {
if (item.isChanged) {
createToast(item);
updateQueue({ ...item, isChanged: false });
}
});
}, [queue, createToast, updateQueue]);
};

View File

@ -1,10 +1,21 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createDownloadUrl, LedgerExportForm } from './ledger-export-form'; import { createDownloadUrl, LedgerExportForm } from './ledger-export-form';
import { formatForInput, toNanoSeconds } from '@vegaprotocol/utils'; import { formatForInput, toNanoSeconds } from '@vegaprotocol/utils';
import {
useLedgerDownloadManager,
useLedgerDownloadFile,
} from './ledger-download-store';
import { Intent } from '@vegaprotocol/ui-toolkit';
const mockSetToast = jest.fn();
jest.mock('@vegaprotocol/ui-toolkit', () => ({
...jest.requireActual('@vegaprotocol/ui-toolkit'),
useToasts: jest.fn(() => [mockSetToast, jest.fn(), jest.fn(() => false)]),
}));
const vegaUrl = 'https://vega-url.co.uk/querystuff'; const vegaUrl = 'https://vega-url.co.uk/querystuff';
const mockResponse = { const mockResponse = {
ok: true,
headers: { get: jest.fn() }, headers: { get: jest.fn() },
blob: () => '', blob: () => '',
}; };
@ -28,6 +39,7 @@ describe('LedgerExportForm', () => {
afterAll(() => { afterAll(() => {
jest.useRealTimers(); jest.useRealTimers();
jest.clearAllMocks();
}); });
it('should be properly rendered', async () => { it('should be properly rendered', async () => {
@ -43,8 +55,6 @@ describe('LedgerExportForm', () => {
// userEvent does not work with faked timers // userEvent does not work with faked timers
fireEvent.click(screen.getByTestId('ledger-download-button')); fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -52,9 +62,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000` }&dateRange.startTimestamp=1691057410000000000`
); );
}); });
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
}); });
it('assetID should be properly change request url', async () => { it('assetID should be properly change request url', async () => {
@ -75,8 +82,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button')); fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -84,9 +89,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000` }&dateRange.startTimestamp=1691057410000000000`
); );
}); });
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
}); });
it('date-from should properly change request url', async () => { it('date-from should properly change request url', async () => {
@ -110,8 +112,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button')); fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -119,10 +119,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=${toNanoSeconds(newDate)}` }&dateRange.startTimestamp=${toNanoSeconds(newDate)}`
); );
}); });
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
}); });
it('date-to should properly change request url', async () => { it('date-to should properly change request url', async () => {
@ -156,10 +152,6 @@ describe('LedgerExportForm', () => {
)}` )}`
); );
}); });
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
}); });
it('Time zone sentence should be properly displayed', () => { it('Time zone sentence should be properly displayed', () => {
@ -205,6 +197,50 @@ describe('LedgerExportForm', () => {
screen.queryByText(/^The downloaded file uses the UTC/) screen.queryByText(/^The downloaded file uses the UTC/)
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it('A toast notification should be displayed', async () => {
useLedgerDownloadFile.setState({ queue: [] });
const TestWrapper = () => {
useLedgerDownloadManager();
return (
<LedgerExportForm
partyId={partyId}
vegaUrl={vegaUrl}
assets={assetsMock}
/>
);
};
render(<TestWrapper />);
expect(screen.getByText('symbol asset-id')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('ledger-download-button'));
const link = `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
Object.keys(assetsMock)[0]
}&dateRange.startTimestamp=1691057410000000000`;
await waitFor(() => {
expect(mockSetToast).toHaveBeenCalledWith({
id: link,
content: expect.any(Object),
onClose: expect.any(Function),
intent: Intent.Primary,
loader: true,
});
expect(global.fetch).toHaveBeenCalledWith(link);
});
mockSetToast.mockClear();
(global.fetch as jest.Mock).mockClear();
fireEvent.click(screen.getByTestId('ledger-download-button')); // click again
await waitFor(() => {
expect(mockSetToast).toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
});
}); });
describe('createDownloadUrl', () => { describe('createDownloadUrl', () => {

View File

@ -1,17 +1,22 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { z } from 'zod'; import { format, subDays } from 'date-fns';
import { import {
TradingButton, Intent,
Loader, Loader,
TradingButton,
TradingFormGroup, TradingFormGroup,
TradingInput, TradingInput,
TradingSelect, TradingSelect,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { toNanoSeconds, VEGA_ID_REGEX } from '@vegaprotocol/utils'; import { z } from 'zod';
import {
formatForInput,
toNanoSeconds,
VEGA_ID_REGEX,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { localLoggerFactory } from '@vegaprotocol/logger'; import { localLoggerFactory } from '@vegaprotocol/logger';
import { formatForInput } from '@vegaprotocol/utils'; import { useLedgerDownloadFile } from './ledger-download-store';
import { subDays } from 'date-fns';
const DEFAULT_EXPORT_FILE_NAME = 'ledger_entries.csv'; const DEFAULT_EXPORT_FILE_NAME = 'ledger_entries.csv';
@ -73,10 +78,14 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
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 [isDownloading, setIsDownloading] = useState(false);
const [assetId, setAssetId] = useState(Object.keys(assets)[0]); const [assetId, setAssetId] = useState(Object.keys(assets)[0]);
const protohost = getProtoHost(vegaUrl); const protohost = getProtoHost(vegaUrl);
const disabled = Boolean(!assetId || isDownloading); const disabled = Boolean(!assetId);
const hasItem = useLedgerDownloadFile((store) => store.hasItem);
const updateDownloadQueue = useLedgerDownloadFile(
(store) => store.updateQueue
);
const assetDropDown = ( const assetDropDown = (
<TradingSelect <TradingSelect
@ -87,7 +96,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
}} }}
className="w-full" className="w-full"
data-testid="select-ledger-asset" data-testid="select-ledger-asset"
disabled={isDownloading}
> >
{Object.keys(assets).map((assetKey) => ( {Object.keys(assets).map((assetKey) => (
<option key={assetKey} value={assetKey}> <option key={assetKey} value={assetKey}>
@ -97,32 +105,78 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
</TradingSelect> </TradingSelect>
); );
const link = createDownloadUrl({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
});
const startDownload = async (event: React.FormEvent<HTMLFormElement>) => { const startDownload = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
try {
const link = createDownloadUrl({ const title = t('Downloading for %s from %s till %s', [
protohost, assets[assetId],
partyId, format(new Date(dateFrom), 'dd MMMM yyyy HH:mm'),
assetId, format(new Date(dateTo || Date.now()), 'dd MMMM yyyy HH:mm'),
dateFrom, ]);
dateTo,
const downloadStoreItem = {
title,
link,
isChanged: true,
};
if (hasItem(link)) {
updateDownloadQueue(downloadStoreItem);
return;
}
const ts = setTimeout(() => {
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Warning,
isDelayed: true,
isChanged: true,
}); });
setIsDownloading(true); }, 1000 * 30);
try {
updateDownloadQueue(downloadStoreItem);
const resp = await fetch(link); 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 { headers } = resp;
const nameHeader = headers.get('content-disposition'); const nameHeader = headers.get('content-disposition');
const filename = nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME; const filename = nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME;
updateDownloadQueue({
...downloadStoreItem,
filename,
});
const blob = await resp.blob(); const blob = await resp.blob();
if (blob) { if (blob) {
const link = document.createElement('a'); updateDownloadQueue({
link.href = URL.createObjectURL(blob); ...downloadStoreItem,
link.download = filename; blob,
link.click(); isDownloaded: true,
isChanged: true,
intent: Intent.Success,
});
} }
} catch (err) { } catch (err) {
localLoggerFactory({ application: 'ledger' }).error('Download file', err); localLoggerFactory({ application: 'ledger' }).error('Download file', err);
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Danger,
isError: true,
isChanged: true,
errorMessage: (err as Error).message || undefined,
});
} finally { } finally {
setIsDownloading(false); clearTimeout(ts);
} }
}; };
@ -145,7 +199,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-from" id="date-from"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
disabled={disabled}
max={maxFromDate} max={maxFromDate}
/> />
</TradingFormGroup> </TradingFormGroup>
@ -156,19 +209,10 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-to" id="date-to"
value={dateTo} value={dateTo}
onChange={(e) => setDateTo(e.target.value)} onChange={(e) => setDateTo(e.target.value)}
disabled={disabled}
max={maxToDate} max={maxToDate}
/> />
</TradingFormGroup> </TradingFormGroup>
<div className="relative text-sm" title={t('Download all to .csv file')}> <div className="relative text-sm" title={t('Download all to .csv file')}>
{isDownloading && (
<div
className="absolute flex items-center justify-center w-full h-full"
data-testid="download-spinner"
>
<Loader size="small" />
</div>
)}
<TradingButton <TradingButton
fill fill
disabled={disabled} disabled={disabled}

View File

@ -8,5 +8,13 @@ When I enter on ledger entries tab in portfolio page
- in the form **Must** see a dropdown for select an asset, in which reports will be downloaded (<a name="7007-LEEN-002" href="#7007-LEEN-002">7007-LEEN-002</a>) - in the form **Must** see a dropdown for select an asset, in which reports will be downloaded (<a name="7007-LEEN-002" href="#7007-LEEN-002">7007-LEEN-002</a>)
- in the form **Must** see inputs for select time period, in which reports will be downloaded (<a name="7007-LEEN-003" href="#7007-LEEN-003">7007-LEEN-003</a>) - in the form **Must** see inputs for select time period, in which reports will be downloaded (<a name="7007-LEEN-003" href="#7007-LEEN-003">7007-LEEN-003</a>)
- default preselected period **Must** be the last 7 days (<a name="7007-LEEN-004" href="#7007-LEEN-004">7007-LEEN-004</a>) - default preselected period **Must** be the last 7 days (<a name="7007-LEEN-004" href="#7007-LEEN-004">7007-LEEN-004</a>)
- during download a loader component **Must** be visible and all interactive elements in the form **Must** be disabled (<a name="7007-LEEN-005" href="#7007-LEEN-005">7007-LEEN-005</a>)
- **Must** see a note about time in file are in UTC and timezone of the user relative to UTC (<a name="7007-LEEN-006" href="#7007-LEEN-006">7007-LEEN-006</a>) - **Must** see a note about time in file are in UTC and timezone of the user relative to UTC (<a name="7007-LEEN-006" href="#7007-LEEN-006">7007-LEEN-006</a>)
- As a user, I **must** see a message saying that this can take several minutes (<a name="7007-LEEN-007" href="#7007-LEEN-007">7007-LEEN-007</a>)
- After half a minute, the message is updated to say something like 'Still in progress' (<a name="7007-LEEN-008" href="#7007-LEEN-008">7007-LEEN-008</a>)
- A toast is shown when the download is complete (<a name="7007-LEEN-009" href="#7007-LEEN-009">7007-LEEN-009</a>)
- The download button should never be disabled
- If user tries to download file which is already in download: (<a name="7007-LEEN-010" href="#7007-LEEN-010">7007-LEEN-010</a>)
- if notification stayed open, nothing happens
- If notification was closed, will be open, no any new request will be fired
- If something has changed in the form (asset, dates, `Date.now`) new download will start.
- The state of the download form should be in sync with the download itself if you navigate away from the page or reload (<a name="7007-LEEN-011" href="#7007-LEEN-011">7007-LEEN-011</a>)