From 9dc9588a14e227f5377dc171bd4b59fb5e0094df Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 17 Oct 2023 17:15:38 +0200 Subject: [PATCH 1/6] chore(ledger): 4895 refactor ledger csv extract (#5053) --- .../components/telemetry/telemetry.tsx | 2 +- apps/trading/pages/transaction-handlers.tsx | 2 + libs/ledger/src/index.ts | 1 + libs/ledger/src/lib/ledger-download-store.tsx | 142 ++++++++++++++++++ .../src/lib/ledger-export-form.spec.tsx | 76 +++++++--- libs/ledger/src/lib/ledger-export-form.tsx | 106 +++++++++---- specs/7007-LEEN-ledger-entries.md | 10 +- 7 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 libs/ledger/src/lib/ledger-download-store.tsx diff --git a/apps/trading/components/telemetry/telemetry.tsx b/apps/trading/components/telemetry/telemetry.tsx index e40a5e2d3..ebe9dc103 100644 --- a/apps/trading/components/telemetry/telemetry.tsx +++ b/apps/trading/components/telemetry/telemetry.tsx @@ -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); diff --git a/apps/trading/pages/transaction-handlers.tsx b/apps/trading/pages/transaction-handlers.tsx index 9e6bc582f..d1060dd6b 100644 --- a/apps/trading/pages/transaction-handlers.tsx +++ b/apps/trading/pages/transaction-handlers.tsx @@ -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; }; diff --git a/libs/ledger/src/index.ts b/libs/ledger/src/index.ts index 6bd8d83a5..57df2aebb 100644 --- a/libs/ledger/src/index.ts +++ b/libs/ledger/src/index.ts @@ -1 +1,2 @@ export * from './lib/ledger-export-form'; +export * from './lib/ledger-download-store'; diff --git a/libs/ledger/src/lib/ledger-download-store.tsx b/libs/ledger/src/lib/ledger-download-store.tsx new file mode 100644 index 000000000..eb19de4df --- /dev/null +++ b/libs/ledger/src/lib/ledger-download-store.tsx @@ -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()( + 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 }) => ( + <> +

{t('Something went wrong')}

+

{message || t('Try again later')}

+ +); + +const InfoContent = ({ progress = false }) => ( + <> +

{t('Please note this can take several minutes.')}

+

{t('You will be notified here when your file is ready.')}

+

+ {progress ? t('Still in progress') : t('Download has been started')} +

+ +); + +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 = ; + break; + case Boolean(item.blob): + content = ( + <> +

{t('Your file is ready')}

+ onDownloadClose(item.link)} + href={URL.createObjectURL(item.blob as Blob)} + download={item.filename} + className="underline" + > + {t('Get file here')} + + + ); + break; + default: + content = ; + } + const toast: Toast = { + id: item.link, + intent: item.intent || Intent.Primary, + content: ( + <> +

{item.title}

+ {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]); +}; diff --git a/libs/ledger/src/lib/ledger-export-form.spec.tsx b/libs/ledger/src/lib/ledger-export-form.spec.tsx index 516d7fb29..755b3eade 100644 --- a/libs/ledger/src/lib/ledger-export-form.spec.tsx +++ b/libs/ledger/src/lib/ledger-export-form.spec.tsx @@ -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 ( + + ); + }; + + render(); + 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', () => { diff --git a/libs/ledger/src/lib/ledger-export-form.tsx b/libs/ledger/src/lib/ledger-export-form.tsx index 57c68890b..135a63f38 100644 --- a/libs/ledger/src/lib/ledger-export-form.tsx +++ b/libs/ledger/src/lib/ledger-export-form.tsx @@ -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 = ( { }} className="w-full" data-testid="select-ledger-asset" - disabled={isDownloading} > {Object.keys(assets).map((assetKey) => ( ); + const link = createDownloadUrl({ + protohost, + partyId, + assetId, + dateFrom, + dateTo, + }); + const startDownload = async (event: React.FormEvent) => { 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} /> @@ -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} />
- {isDownloading && ( -
- -
- )} 7007-LEEN-002) - in the form **Must** see inputs for select time period, in which reports will be downloaded (7007-LEEN-003) - default preselected period **Must** be the last 7 days (7007-LEEN-004) -- during download a loader component **Must** be visible and all interactive elements in the form **Must** be disabled (7007-LEEN-005) - **Must** see a note about time in file are in UTC and timezone of the user relative to UTC (7007-LEEN-006) +- As a user, I **must** see a message saying that this can take several minutes (7007-LEEN-007) +- After half a minute, the message is updated to say something like 'Still in progress' (7007-LEEN-008) +- A toast is shown when the download is complete (7007-LEEN-009) +- The download button should never be disabled + - If user tries to download file which is already in download: (7007-LEEN-010) + - 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 (7007-LEEN-011) From e7c3b5054ceed2494d90dea9c8b198618c3b6b20 Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Tue, 17 Oct 2023 09:56:48 -0700 Subject: [PATCH 2/6] fix(trading): market selector dropdown not updating (#5027) --- .../market-selector/market-selector-item.tsx | 5 +- .../market-selector/market-selector.spec.tsx | 33 ++++- .../use-market-selector-list.spec.tsx | 121 +++++++++++++++--- .../use-market-selector-list.ts | 8 +- .../src/lib/cells/market-name-cell.tsx | 19 +-- .../src/lib/__generated__/markets-data.ts | 5 +- libs/markets/src/lib/markets-data.graphql | 1 + libs/markets/src/lib/markets-data.mock.ts | 1 + 8 files changed, 158 insertions(+), 35 deletions(-) diff --git a/apps/trading/components/market-selector/market-selector-item.tsx b/apps/trading/components/market-selector/market-selector-item.tsx index bf9007208..6c68def54 100644 --- a/apps/trading/components/market-selector/market-selector-item.tsx +++ b/apps/trading/components/market-selector/market-selector-item.tsx @@ -56,6 +56,7 @@ const MarketData = ({ variables: { marketId: market.id, }, + fetchPolicy: 'no-cache', }); const marketData = data?.marketsData[0]; @@ -70,6 +71,8 @@ const MarketData = ({ const marketTradingMode = marketData ? marketData.marketTradingMode + : market.data + ? market.data.marketTradingMode : market.tradingMode; const mode = [ @@ -95,7 +98,7 @@ const MarketData = ({ <>

- + {market.tradableInstrument.instrument.code} {allProducts && productType && ( diff --git a/apps/trading/components/market-selector/market-selector.spec.tsx b/apps/trading/components/market-selector/market-selector.spec.tsx index 01685cf33..1df96bf34 100644 --- a/apps/trading/components/market-selector/market-selector.spec.tsx +++ b/apps/trading/components/market-selector/market-selector.spec.tsx @@ -2,7 +2,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MarketSelector } from './market-selector'; import { useMarketList } from '@vegaprotocol/markets'; -import { createMarketFragment } from '@vegaprotocol/mock'; +import { + createMarketFragment, + createMarketsDataFragment, +} from '@vegaprotocol/mock'; import { MarketState } from '@vegaprotocol/types'; import { MemoryRouter } from 'react-router-dom'; import type { ReactNode } from 'react'; @@ -36,6 +39,10 @@ describe('MarketSelector', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'a', @@ -56,7 +63,10 @@ describe('MarketSelector', () => { }), createMarketFragment({ id: 'market-1', - state: MarketState.STATE_SUSPENDED, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_SUSPENDED, + }), tradableInstrument: { instrument: { code: 'b', @@ -77,7 +87,10 @@ describe('MarketSelector', () => { }), createMarketFragment({ id: 'market-2', - state: MarketState.STATE_CLOSED, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_CLOSED, + }), tradableInstrument: { instrument: { product: { @@ -91,7 +104,10 @@ describe('MarketSelector', () => { }), createMarketFragment({ id: 'market-3', - state: MarketState.STATE_ACTIVE, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'c', @@ -112,6 +128,10 @@ describe('MarketSelector', () => { }), createMarketFragment({ id: 'market-4', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'cd', @@ -132,7 +152,10 @@ describe('MarketSelector', () => { }), ]; - const activeMarkets = markets.filter((m) => isMarketActive(m.state)); + const activeMarkets = markets.filter((m) => + // @ts-ignore candles get joined outside this type + isMarketActive(m.data.marketState) + ); mockUseMarketList.mockReturnValue({ data: markets, loading: false, diff --git a/apps/trading/components/market-selector/use-market-selector-list.spec.tsx b/apps/trading/components/market-selector/use-market-selector-list.spec.tsx index 3e567dfef..5349f3ae6 100644 --- a/apps/trading/components/market-selector/use-market-selector-list.spec.tsx +++ b/apps/trading/components/market-selector/use-market-selector-list.spec.tsx @@ -4,7 +4,10 @@ import { useMarketSelectorList } from './use-market-selector-list'; import { isMarketActive } from '../../lib/utils'; import { Product } from './product-selector'; import { Sort } from './sort-dropdown'; -import { createMarketFragment } from '@vegaprotocol/mock'; +import { + createMarketFragment, + createMarketsDataFragment, +} from '@vegaprotocol/mock'; import { MarketState } from '@vegaprotocol/types'; import { useMarketList } from '@vegaprotocol/markets'; import type { Filter } from './market-selector'; @@ -31,22 +34,40 @@ describe('useMarketSelectorList', () => { it('returns all markets active and suspended markets', () => { const markets = [ - createMarketFragment({ id: 'market-0' }), + createMarketFragment({ + id: 'market-0', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), + }), createMarketFragment({ id: 'market-1', - state: MarketState.STATE_SUSPENDED, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_SUSPENDED, + }), }), createMarketFragment({ id: 'market-2', - state: MarketState.STATE_CLOSED, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_CLOSED, + }), }), createMarketFragment({ id: 'market-3', - state: MarketState.STATE_CLOSED, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_CLOSED, + }), }), createMarketFragment({ id: 'market-4', - state: MarketState.STATE_PENDING, + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_PENDING, + }), }), ]; mockUseMarketList.mockReturnValue({ @@ -56,7 +77,8 @@ describe('useMarketSelectorList', () => { }); const { result } = setup(); const expectedFilteredMarkets = markets.filter((m) => - isMarketActive(m.state) + // @ts-ignore candles get joined outside this type + isMarketActive(m.data.marketState) ); expect(result.current).toEqual({ data: markets, @@ -70,6 +92,10 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -90,6 +116,10 @@ describe('useMarketSelectorList', () => { // }), createMarketFragment({ id: 'market-2', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -135,6 +165,10 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -148,6 +182,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-1', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -161,6 +199,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-2', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -174,6 +216,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-3', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { product: { @@ -238,6 +284,10 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'abc', @@ -247,6 +297,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-1', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'def', @@ -256,6 +310,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-2', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'defg', @@ -265,6 +323,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-3', + // @ts-ignore candles get joined outside this type + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), tradableInstrument: { instrument: { code: 'ggg', @@ -333,11 +395,11 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', - state: MarketState.STATE_ACTIVE, // @ts-ignore data not on fragment - data: { + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, markPrice: '1', - }, + }), // @ts-ignore candles not on fragment candles: [ { @@ -349,9 +411,10 @@ describe('useMarketSelectorList', () => { id: 'market-1', state: MarketState.STATE_ACTIVE, // @ts-ignore data not on fragment - data: { + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, markPrice: '1', - }, + }), // @ts-ignore candles not on fragment candles: [ { @@ -363,9 +426,10 @@ describe('useMarketSelectorList', () => { id: 'market-2', state: MarketState.STATE_ACTIVE, // @ts-ignore data not on fragment - data: { + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, markPrice: '1', - }, + }), // @ts-ignore candles not on fragment candles: [ { @@ -377,9 +441,10 @@ describe('useMarketSelectorList', () => { id: 'market-3', state: MarketState.STATE_ACTIVE, // @ts-ignore data not on fragment - data: { + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, markPrice: '1', - }, + }), // @ts-ignore candles not on fragment candles: [ { @@ -414,6 +479,10 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), // @ts-ignore actual fragment doesn't contain candles and is joined later candles: [ { @@ -426,6 +495,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-1', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), // @ts-ignore actual fragment doesn't contain candles and is joined later candles: [ { @@ -438,6 +511,10 @@ describe('useMarketSelectorList', () => { }), createMarketFragment({ id: 'market-2', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), // @ts-ignore actual fragment doesn't contain candles and is joined later candles: [ { @@ -482,18 +559,30 @@ describe('useMarketSelectorList', () => { const markets = [ createMarketFragment({ id: 'market-0', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), marketTimestamps: { open: subDays(new Date(), 3).toISOString(), }, }), createMarketFragment({ id: 'market-1', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), marketTimestamps: { open: subDays(new Date(), 1).toISOString(), }, }), createMarketFragment({ id: 'market-2', + // @ts-ignore data not on fragment + data: createMarketsDataFragment({ + marketState: MarketState.STATE_ACTIVE, + }), marketTimestamps: { open: subDays(new Date(), 2).toISOString(), }, diff --git a/apps/trading/components/market-selector/use-market-selector-list.ts b/apps/trading/components/market-selector/use-market-selector-list.ts index 52ffc2e6e..ea6a5f8c0 100644 --- a/apps/trading/components/market-selector/use-market-selector-list.ts +++ b/apps/trading/components/market-selector/use-market-selector-list.ts @@ -22,8 +22,12 @@ export const useMarketSelectorList = ({ const markets = useMemo(() => { if (!data?.length) return []; const markets = data - // only active - .filter((m) => isMarketActive(m.state)) + // show only active markets, using m.data.marketState as this will be + // data that will get refreshed when calling reload + .filter((m) => { + if (!m.data) return false; + return isMarketActive(m.data.marketState); + }) // only selected product type .filter((m) => { if ( diff --git a/libs/datagrid/src/lib/cells/market-name-cell.tsx b/libs/datagrid/src/lib/cells/market-name-cell.tsx index a0138dc1a..c989adf32 100644 --- a/libs/datagrid/src/lib/cells/market-name-cell.tsx +++ b/libs/datagrid/src/lib/cells/market-name-cell.tsx @@ -14,16 +14,17 @@ export const MarketProductPill = ({ }: { productType?: ProductType; }) => { + if (!productType) { + return null; + } return ( - productType && ( - - {ProductTypeShortName[productType]} - - ) + + {ProductTypeShortName[productType]} + ); }; diff --git a/libs/markets/src/lib/__generated__/markets-data.ts b/libs/markets/src/lib/__generated__/markets-data.ts index 9de06b35e..8acff0f4f 100644 --- a/libs/markets/src/lib/__generated__/markets-data.ts +++ b/libs/markets/src/lib/__generated__/markets-data.ts @@ -3,12 +3,12 @@ import * as Types from '@vegaprotocol/types'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; -export type MarketsDataFieldsFragment = { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } }; +export type MarketsDataFieldsFragment = { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketState: Types.MarketState, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } }; export type MarketsDataQueryVariables = Types.Exact<{ [key: string]: never; }>; -export type MarketsDataQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', data?: { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } } | null } }> } | null }; +export type MarketsDataQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', data?: { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketState: Types.MarketState, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } } | null } }> } | null }; export const MarketsDataFieldsFragmentDoc = gql` fragment MarketsDataFields on MarketData { @@ -20,6 +20,7 @@ export const MarketsDataFieldsFragmentDoc = gql` markPrice trigger staticMidPrice + marketState marketTradingMode indicativeVolume indicativePrice diff --git a/libs/markets/src/lib/markets-data.graphql b/libs/markets/src/lib/markets-data.graphql index 0202c821f..c956a3f55 100644 --- a/libs/markets/src/lib/markets-data.graphql +++ b/libs/markets/src/lib/markets-data.graphql @@ -7,6 +7,7 @@ fragment MarketsDataFields on MarketData { markPrice trigger staticMidPrice + marketState marketTradingMode indicativeVolume indicativePrice diff --git a/libs/markets/src/lib/markets-data.mock.ts b/libs/markets/src/lib/markets-data.mock.ts index 8e083b44c..8ce979974 100644 --- a/libs/markets/src/lib/markets-data.mock.ts +++ b/libs/markets/src/lib/markets-data.mock.ts @@ -34,6 +34,7 @@ export const createMarketsDataFragment = ( __typename: 'Market', }, marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, + marketState: Schema.MarketState.STATE_ACTIVE, staticMidPrice: '0', indicativePrice: '0', bestStaticBidPrice: '0', From 835ee6424344e018e22b732087ea39bd25d5514b Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Tue, 17 Oct 2023 20:05:53 -0700 Subject: [PATCH 3/6] fix(trading): hydration error due to loader (#5043) --- apps/trading/pages/ssr-loader.tsx | 11 +++++------ libs/ui-toolkit/src/components/loader/loader.tsx | 7 +++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/trading/pages/ssr-loader.tsx b/apps/trading/pages/ssr-loader.tsx index 2bb2a926b..89ec0c91c 100644 --- a/apps/trading/pages/ssr-loader.tsx +++ b/apps/trading/pages/ssr-loader.tsx @@ -1,8 +1,8 @@ -export const SSRLoader = () => { - const randomDelay = () => { - return parseFloat((Math.random() * (4 - 1) + 1).toFixed(2)); - }; +import { pseudoRandom } from '@vegaprotocol/ui-toolkit'; +const generate = pseudoRandom(1); + +export const SSRLoader = () => { return (
{ width: 10, height: 10, animation: 'flickering 0.4s linear alternate infinite', - animationDelay: `-${randomDelay()}s`, + animationDelay: `-${generate()}s`, animationDirection: i % 2 === 0 ? 'reverse' : 'alternate', background: 'black', - opacity: Math.random() > 0.5 ? 1 : 0, }} /> ); diff --git a/libs/ui-toolkit/src/components/loader/loader.tsx b/libs/ui-toolkit/src/components/loader/loader.tsx index 1a591708b..71ad1999d 100644 --- a/libs/ui-toolkit/src/components/loader/loader.tsx +++ b/libs/ui-toolkit/src/components/loader/loader.tsx @@ -1,8 +1,7 @@ import classNames from 'classnames'; -import { useMemo } from 'react'; import styles from './loader.module.scss'; -const pseudoRandom = (seed: number) => { +export const pseudoRandom = (seed: number) => { let value = seed; return () => { value = (value * 16807) % 2147483647; @@ -10,6 +9,8 @@ const pseudoRandom = (seed: number) => { }; }; +const generate = pseudoRandom(1); + export interface LoaderProps { size?: 'small' | 'large'; forceTheme?: 'dark' | 'light'; @@ -27,8 +28,6 @@ export const Loader = ({ size = 'large', forceTheme }: LoaderProps) => { size === 'small' ? 'w-[15px] h-[15px]' : 'w-[50px] h-[50px]'; const items = size === 'small' ? 9 : 25; - const generate = useMemo(() => pseudoRandom(1), []); - return (
From 6ac79e9f6d5ec3ea1fe98136f5ca439a20976cf1 Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Tue, 17 Oct 2023 20:14:48 -0700 Subject: [PATCH 4/6] chore(trading,explorer,governance): update codeowners so dev team only is required for reviews --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index d54deb8db..8117415dd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ -* @vegaprotocol/frontend @vegaprotocol/frontend-qa +* @vegaprotocol/frontend *.graphql @vegaprotocol/core From 6d2a2ea0a009479451a173e8042277694e240abc Mon Sep 17 00:00:00 2001 From: "m.ray" <16125548+MadalinaRaicu@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:56:50 +0300 Subject: [PATCH 5/6] chore(trading): update snap version to 0.3.1 (#5059) --- libs/wallet/src/connectors/snap-connector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wallet/src/connectors/snap-connector.ts b/libs/wallet/src/connectors/snap-connector.ts index 2a77da97a..89744f613 100644 --- a/libs/wallet/src/connectors/snap-connector.ts +++ b/libs/wallet/src/connectors/snap-connector.ts @@ -41,7 +41,7 @@ const ethereumRequest = (args: RequestArguments): Promise => { export const LOCAL_SNAP_ID = 'local:http://localhost:8080'; export const DEFAULT_SNAP_ID = 'npm:@vegaprotocol/snap'; -export const DEFAULT_SNAP_VERSION = '0.2.0'; +export const DEFAULT_SNAP_VERSION = '0.3.1'; type GetSnapsResponse = Record; From abf84b9d458913ac96791a05088ebf8fb786ddb8 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 18 Oct 2023 11:11:05 +0200 Subject: [PATCH 6/6] chore(ui-toolkit): 4970 async renderer - improve error handling (#5063) --- .../async-renderer/async-renderer.spec.tsx | 20 ++++++++++++++++++- .../async-renderer/async-renderer.tsx | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libs/ui-toolkit/src/components/async-renderer/async-renderer.spec.tsx b/libs/ui-toolkit/src/components/async-renderer/async-renderer.spec.tsx index b03f50ace..05b5165bf 100644 --- a/libs/ui-toolkit/src/components/async-renderer/async-renderer.spec.tsx +++ b/libs/ui-toolkit/src/components/async-renderer/async-renderer.spec.tsx @@ -2,8 +2,8 @@ import { render, act, screen } from '@testing-library/react'; import { AsyncRenderer } from './async-renderer'; describe('AsyncRenderer', () => { + const reload = jest.fn(); it('timeout error should render button', async () => { - const reload = jest.fn(); await act(() => { render( { }); expect(reload).toHaveBeenCalled(); }); + + it('errors should be handled properly', async () => { + const message = 'Node has been collapsed'; + await act(() => { + render( + + ); + }); + + expect( + screen.getByText(`Something went wrong: ${message}`) + ).toBeInTheDocument(); + }); }); diff --git a/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx b/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx index ed0b41b37..7ee333d7d 100644 --- a/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx +++ b/libs/ui-toolkit/src/components/async-renderer/async-renderer.tsx @@ -29,7 +29,7 @@ export function AsyncRenderer({ reload, }: AsyncRendererProps) { if (error) { - if (!data) { + if (!data || (Array.isArray(data) && !data.length)) { return (