chore(ledger): 4895 refactor ledger csv extract (#5053)
This commit is contained in:
parent
a0844d41bf
commit
9dc9588a14
@ -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);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './lib/ledger-export-form';
|
||||
export * from './lib/ledger-download-store';
|
||||
|
142
libs/ledger/src/lib/ledger-download-store.tsx
Normal file
142
libs/ledger/src/lib/ledger-download-store.tsx
Normal 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]);
|
||||
};
|
@ -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', () => {
|
||||
|
@ -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}
|
||||
|
@ -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>)
|
||||
|
Loading…
Reference in New Issue
Block a user