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 { 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 = () => {
const onboardingDissmissed = useOnboardingStore((store) => store.dismissed);

View File

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

View File

@ -1 +1,2 @@
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 { createDownloadUrl, LedgerExportForm } from './ledger-export-form';
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 mockResponse = {
ok: true,
headers: { get: jest.fn() },
blob: () => '',
};
@ -28,6 +39,7 @@ describe('LedgerExportForm', () => {
afterAll(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
it('should be properly rendered', async () => {
@ -43,8 +55,6 @@ describe('LedgerExportForm', () => {
// userEvent does not work with faked timers
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -52,9 +62,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('assetID should be properly change request url', async () => {
@ -75,8 +82,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -84,9 +89,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('date-from should properly change request url', async () => {
@ -110,8 +112,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@ -119,10 +119,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=${toNanoSeconds(newDate)}`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
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', () => {
@ -205,6 +197,50 @@ describe('LedgerExportForm', () => {
screen.queryByText(/^The downloaded file uses the UTC/)
).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', () => {

View File

@ -1,17 +1,22 @@
import { useRef, useState } from 'react';
import { z } from 'zod';
import { format, subDays } from 'date-fns';
import {
TradingButton,
Intent,
Loader,
TradingButton,
TradingFormGroup,
TradingInput,
TradingSelect,
} 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 { localLoggerFactory } from '@vegaprotocol/logger';
import { formatForInput } from '@vegaprotocol/utils';
import { subDays } from 'date-fns';
import { useLedgerDownloadFile } from './ledger-download-store';
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 maxToDate = formatForInput(now.current);
const [isDownloading, setIsDownloading] = useState(false);
const [assetId, setAssetId] = useState(Object.keys(assets)[0]);
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 = (
<TradingSelect
@ -87,7 +96,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
}}
className="w-full"
data-testid="select-ledger-asset"
disabled={isDownloading}
>
{Object.keys(assets).map((assetKey) => (
<option key={assetKey} value={assetKey}>
@ -97,32 +105,78 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
</TradingSelect>
);
const link = createDownloadUrl({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
});
const startDownload = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const link = createDownloadUrl({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
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,
});
setIsDownloading(true);
}, 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) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
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 {
setIsDownloading(false);
clearTimeout(ts);
}
};
@ -145,7 +199,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-from"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
disabled={disabled}
max={maxFromDate}
/>
</TradingFormGroup>
@ -156,19 +209,10 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-to"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
disabled={disabled}
max={maxToDate}
/>
</TradingFormGroup>
<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
fill
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 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>)
- 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>)
- 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>)