diff --git a/tauri-app/src/lib/components/CardProperty.svelte b/tauri-app/src/lib/components/CardProperty.svelte index 04c13b36f..7676fa5bd 100644 --- a/tauri-app/src/lib/components/CardProperty.svelte +++ b/tauri-app/src/lib/components/CardProperty.svelte @@ -1,4 +1,4 @@ -
+
diff --git a/tauri-app/src/lib/components/LightweightChart.svelte b/tauri-app/src/lib/components/charts/LightweightChart.svelte similarity index 93% rename from tauri-app/src/lib/components/LightweightChart.svelte rename to tauri-app/src/lib/components/charts/LightweightChart.svelte index 72b1be3f1..37da56a47 100644 --- a/tauri-app/src/lib/components/LightweightChart.svelte +++ b/tauri-app/src/lib/components/charts/LightweightChart.svelte @@ -75,7 +75,6 @@ function setData() { if (series === undefined || data.length === 0) return; - series.setData(data); setTimeScale(); } @@ -96,7 +95,7 @@ } $: timeDelta, setTimeScale(); - $: data, setData(); + $: data, series, setData(); $: $lightweightChartsTheme, setOptions(); onMount(() => { @@ -153,11 +152,16 @@
{#if data.length === 0 && !loading} -
+
{emptyMessage}
{:else} -
+
{/if}
diff --git a/tauri-app/src/lib/components/LightweightChartHistogram.svelte b/tauri-app/src/lib/components/charts/LightweightChartHistogram.svelte similarity index 100% rename from tauri-app/src/lib/components/LightweightChartHistogram.svelte rename to tauri-app/src/lib/components/charts/LightweightChartHistogram.svelte diff --git a/tauri-app/src/lib/components/LightweightChartLine.svelte b/tauri-app/src/lib/components/charts/LightweightChartLine.svelte similarity index 100% rename from tauri-app/src/lib/components/LightweightChartLine.svelte rename to tauri-app/src/lib/components/charts/LightweightChartLine.svelte diff --git a/tauri-app/src/lib/components/charts/TanstackLightweightChartLine.svelte b/tauri-app/src/lib/components/charts/TanstackLightweightChartLine.svelte new file mode 100644 index 000000000..2a62bc87d --- /dev/null +++ b/tauri-app/src/lib/components/charts/TanstackLightweightChartLine.svelte @@ -0,0 +1,27 @@ + + + diff --git a/tauri-app/src/lib/components/charts/VaultBalanceChart.svelte b/tauri-app/src/lib/components/charts/VaultBalanceChart.svelte new file mode 100644 index 000000000..ef2f50668 --- /dev/null +++ b/tauri-app/src/lib/components/charts/VaultBalanceChart.svelte @@ -0,0 +1,31 @@ + + +{#if vault} + timestampSecondsToUTCTimestamp(BigInt(d.timestamp))} + valueTransform={(d) => + bigintToFloat(BigInt(d.new_vault_balance), Number(vault.token.decimals ?? 0))} + emptyMessage="No deposits or withdrawals found" + /> +{/if} diff --git a/tauri-app/src/lib/components/TanstackPageContentDetail.svelte b/tauri-app/src/lib/components/detail/TanstackPageContentDetail.svelte similarity index 66% rename from tauri-app/src/lib/components/TanstackPageContentDetail.svelte rename to tauri-app/src/lib/components/detail/TanstackPageContentDetail.svelte index fdd53e690..9930ec935 100644 --- a/tauri-app/src/lib/components/TanstackPageContentDetail.svelte +++ b/tauri-app/src/lib/components/detail/TanstackPageContentDetail.svelte @@ -5,18 +5,28 @@ // eslint-disable-next-line no-undef export let query: CreateQueryResult; export let emptyMessage = 'Not found'; + + // We need to explicitly define the data type as non-nullable here because + // doing it in the component body ({#if $query.data}) doesn't make the slot + // prop non-nullable when used in the parent component. + + // eslint-disable-next-line no-undef + let data: NonNullable; + $: if ($query.data) { + data = $query.data; + } -{#if $query.data} +{#if data}
- +
- +
- +
diff --git a/tauri-app/src/lib/components/TanstackPageContentDetail.test.svelte b/tauri-app/src/lib/components/detail/TanstackPageContentDetail.test.svelte similarity index 100% rename from tauri-app/src/lib/components/TanstackPageContentDetail.test.svelte rename to tauri-app/src/lib/components/detail/TanstackPageContentDetail.test.svelte diff --git a/tauri-app/src/lib/components/TanstackPageContentDetail.test.ts b/tauri-app/src/lib/components/detail/TanstackPageContentDetail.test.ts similarity index 100% rename from tauri-app/src/lib/components/TanstackPageContentDetail.test.ts rename to tauri-app/src/lib/components/detail/TanstackPageContentDetail.test.ts diff --git a/tauri-app/src/lib/components/detail/VaultDetail.svelte b/tauri-app/src/lib/components/detail/VaultDetail.svelte new file mode 100644 index 000000000..481984476 --- /dev/null +++ b/tauri-app/src/lib/components/detail/VaultDetail.svelte @@ -0,0 +1,136 @@ + + + + +
+ {data.token.name} +
+
+ {#if $walletAddressMatchesOrBlank(data.owner)} + + + {/if} +
+
+ + + Vault ID + {bigintStringToHex(data.vault_id)} + + + + Owner Address + + + + + + + Token address + + + + + + + Balance + {formatUnits(BigInt(data.balance), Number(data.token.decimals ?? 0))} + {data.token.symbol} + + + + Orders as input + +

+ {#if data.orders_as_input && data.orders_as_input.length > 0} + {#each data.orders_as_input as order} + + {/each} + {:else} + None + {/if} +

+
+
+ + + Orders as output + +

+ {#if data.orders_as_output && data.orders_as_output.length > 0} + {#each data.orders_as_output as order} + + {/each} + {:else} + None + {/if} +

+
+
+
+ + + + + + +
diff --git a/tauri-app/src/lib/components/detail/VaultDetail.test.ts b/tauri-app/src/lib/components/detail/VaultDetail.test.ts new file mode 100644 index 000000000..7b912de39 --- /dev/null +++ b/tauri-app/src/lib/components/detail/VaultDetail.test.ts @@ -0,0 +1,346 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import { test, vi } from 'vitest'; +import { expect } from '$lib/test/matchers'; +import { QueryClient } from '@tanstack/svelte-query'; +import VaultDetail from './VaultDetail.svelte'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import type { Vault } from '$lib/typeshare/vaultDetail'; +import { goto } from '$app/navigation'; +import { handleDepositModal, handleWithdrawModal } from '$lib/services/modal'; + +const { mockWalletAddressMatchesOrBlankStore } = await vi.hoisted( + () => import('$lib/mocks/wallets'), +); + +vi.mock('$lib/stores/wallets', async () => { + return { + walletAddressMatchesOrBlank: mockWalletAddressMatchesOrBlankStore, + }; +}); + +vi.mock('$lib/stores/settings', async (importOriginal) => { + const { writable } = await import('svelte/store'); + const { mockSettingsStore } = await import('$lib/mocks/settings'); + + const _activeOrderbook = writable(); + + return { + ...((await importOriginal()) as object), + settings: mockSettingsStore, + subgraphUrl: writable('https://example.com'), + activeOrderbook: { + ..._activeOrderbook, + load: vi.fn(() => _activeOrderbook.set(true)), + }, + }; +}); + +vi.mock('$lib/services/modal', async () => { + return { + handleDepositGenericModal: vi.fn(), + handleDepositModal: vi.fn(), + handleWithdrawModal: vi.fn(), + }; +}); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), +})); + +vi.mock('lightweight-charts', async (importOriginal) => ({ + ...((await importOriginal()) as object), + createChart: vi.fn(() => ({ + addLineSeries: vi.fn(), + remove(): void {}, + applyOptions: vi.fn(), + })), +})); + +test('calls the vault detail query fn with the correct vault id', async () => { + let receivedId: string; + mockIPC((cmd, args) => { + if (cmd === 'vault_detail') { + receivedId = args.id as string; + return []; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => expect(receivedId).toEqual('100')); +}); + +test('shows the correct empty message when the query returns no data', async () => { + mockIPC((cmd) => { + if (cmd === 'vault_detail') { + return null; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => expect(screen.getByTestId('emptyMessage')).toBeInTheDocument()); +}); + +test('shows the correct data when the query returns data', async () => { + const mockData: Vault = { + id: '1', + vault_id: '0xabc', + owner: '0x123', + token: { + id: '0x456', + address: '0x456', + name: 'USDC coin', + symbol: 'USDC', + decimals: '6', + }, + balance: '100000000000', + orders_as_input: [], + orders_as_output: [], + balance_changes: [], + }; + mockIPC((cmd) => { + if (cmd === 'vault_detail') { + return mockData; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByTestId('vaultDetailTokenName')).toHaveTextContent('USDC coin'); + expect(screen.getByTestId('vaultDetailVaultId')).toHaveTextContent('Vault ID 0xabc'); + expect(screen.getByTestId('vaultDetailOwnerAddress')).toHaveTextContent( + 'Owner Address 0x123...0x123', + ); + expect(screen.getByTestId('vaultDetailTokenAddress')).toHaveTextContent( + 'Token address 0x456...0x456', + ); + expect(screen.getByTestId('vaultDetailBalance')).toHaveTextContent('Balance 100000 USDC'); + expect(screen.queryByTestId('vaultDetailOrdersAsInput')).toHaveTextContent('None'); + expect(screen.queryByTestId('vaulDetailOrdersAsOutput')).toHaveTextContent('None'); + }); +}); + +test('shows the correct data when the query returns data with orders', async () => { + const mockData: Vault = { + id: '1', + vault_id: '0xabc', + owner: '0x123', + token: { + id: '0x456', + address: '0x456', + name: 'USDC coin', + symbol: 'USDC', + decimals: '6', + }, + balance: '100000000000', + orders_as_input: [ + { + id: '1', + order_hash: '0x123', + active: true, + }, + { + id: '2', + order_hash: '0x456', + active: false, + }, + ], + orders_as_output: [ + { + id: '3', + order_hash: '0x789', + active: true, + }, + { + id: '4', + order_hash: '0xabc', + active: false, + }, + ], + balance_changes: [], + }; + mockIPC((cmd) => { + if (cmd === 'vault_detail') { + return mockData; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(async () => { + expect( + await screen.findAllByTestId('vaultDetailOrderAsInputOrder', { exact: false }), + ).toHaveLength(2); + expect( + await screen.findAllByTestId('vaultDetailOrderAsOutputOrder', { exact: false }), + ).toHaveLength(2); + }); + + const orderAsInputOrders = screen.getAllByTestId('vaultDetailOrderAsInputOrder', { + exact: false, + }); + expect(orderAsInputOrders[0]).toHaveTextContent('0x123...0x123'); + expect(orderAsInputOrders[1]).toHaveTextContent('0x456...0x456'); + + const orderAsOutputOrders = screen.getAllByTestId('vaultDetailOrderAsOutputOrder', { + exact: false, + }); + + expect(orderAsOutputOrders[0]).toHaveTextContent('0x789...0x789'); + expect(orderAsOutputOrders[1]).toHaveTextContent('0xabc...0xabc'); +}); + +test('orders link to the correct order', async () => { + const mockData: Vault = { + id: '1', + vault_id: '0xabc', + owner: '0x123', + token: { + id: '0x456', + address: '0x456', + name: 'USDC coin', + symbol: 'USDC', + decimals: '6', + }, + balance: '100000000000', + orders_as_input: [ + { + id: '1', + order_hash: '0x123', + active: true, + }, + { + id: '2', + order_hash: '0x456', + active: false, + }, + ], + orders_as_output: [ + { + id: '3', + order_hash: '0x789', + active: true, + }, + { + id: '4', + order_hash: '0xabc', + active: false, + }, + ], + balance_changes: [], + }; + mockIPC((cmd) => { + if (cmd === 'vault_detail') { + return mockData; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + let ordersAsOutput, ordersAsInput; + + await waitFor(async () => { + ordersAsInput = await screen.findAllByTestId('vaultDetailOrderAsInputOrder', { exact: false }); + await Promise.all( + ordersAsInput.map(async (order) => { + await order.click(); + expect(goto).toHaveBeenCalledWith(`/orders/${order.getAttribute('data-order')}`); + }), + ); + }); + + await waitFor(async () => { + ordersAsOutput = await screen.findAllByTestId('vaultDetailOrderAsOutputOrder', { + exact: false, + }); + await Promise.all( + ordersAsOutput.map(async (order) => { + await order.click(); + expect(goto).toHaveBeenCalledWith(`/orders/${order.getAttribute('data-order')}`); + }), + ); + }); +}); + +test('shows deposit and withdraw buttons if owner wallet matches, opens correct modals', async () => { + const mockData: Vault = { + id: '1', + vault_id: '0xabc', + owner: '0x123', + token: { + id: '0x456', + address: '0x456', + name: 'USDC coin', + symbol: 'USDC', + decimals: '6', + }, + balance: '100000000000', + orders_as_input: [], + orders_as_output: [], + balance_changes: [], + }; + + mockIPC((cmd) => { + if (cmd === 'vault_detail') { + return mockData; + } + }); + + const queryClient = new QueryClient(); + + render(VaultDetail, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => { + expect(screen.queryByTestId('vaultDetailDepositButton')).not.toBeInTheDocument(); + expect(screen.queryByTestId('vaultDetailWithdrawButton')).not.toBeInTheDocument(); + }); + + mockWalletAddressMatchesOrBlankStore.set(() => true); + + await waitFor(() => { + expect(screen.queryByTestId('vaultDetailDepositButton')).toBeInTheDocument(); + expect(screen.queryByTestId('vaultDetailWithdrawButton')).toBeInTheDocument(); + }); + + screen.getByTestId('vaultDetailDepositButton').click(); + + await waitFor(() => { + expect(handleDepositModal).toHaveBeenCalledWith(mockData); + }); + + screen.getByTestId('vaultDetailWithdrawButton').click(); + + await waitFor(() => { + expect(handleWithdrawModal).toHaveBeenCalledWith(mockData); + }); +}); diff --git a/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.svelte b/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.svelte new file mode 100644 index 000000000..114202a8e --- /dev/null +++ b/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.svelte @@ -0,0 +1,72 @@ + + + + + Vault Balance Changes + + + Date + Sender + Transaction Hash + Balance Change + Balance + Type + + + + + {formatTimestampSecondsAsLocal(BigInt(item.timestamp))} + + + + + + + + + {formatUnits(BigInt(item.amount), Number(item.vault.token.decimals ?? 0))} + {item.vault.token.symbol} + + + {formatUnits(BigInt(item.new_vault_balance), Number(item.vault.token.decimals ?? 0))} + {item.vault.token.symbol} + + + {item.__typename} + + + diff --git a/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.test.ts b/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.test.ts new file mode 100644 index 000000000..c47a8d670 --- /dev/null +++ b/tauri-app/src/lib/components/tables/VaultBalanceChangesTable.test.ts @@ -0,0 +1,161 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import { test, vi } from 'vitest'; +import { expect } from '$lib/test/matchers'; +import { QueryClient } from '@tanstack/svelte-query'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import VaultBalanceChangesTable from './VaultBalanceChangesTable.svelte'; +import type { VaultBalanceChange } from '$lib/typeshare/vaultBalanceChangesList'; +import { formatTimestampSecondsAsLocal } from '$lib/utils/time'; + +vi.mock('$lib/stores/settings', async (importOriginal) => { + const { writable } = await import('svelte/store'); + const { mockSettingsStore } = await import('$lib/mocks/settings'); + + const _activeOrderbook = writable(); + + return { + ...((await importOriginal()) as object), + settings: mockSettingsStore, + subgraphUrl: writable('https://example.com'), + activeOrderbook: { + ..._activeOrderbook, + load: vi.fn(() => _activeOrderbook.set(true)), + }, + }; +}); + +test('renders the vault list table with correct data', async () => { + const queryClient = new QueryClient(); + + const mockVaultBalanceChanges: VaultBalanceChange[] = [ + { + __typename: 'Withdrawal', + amount: '1000', + old_vault_balance: '5000', + new_vault_balance: '4000', + vault: { + id: 'vault1', + token: { + id: 'token1', + address: '0xTokenAddress1', + name: 'Token1', + symbol: 'TKN1', + decimals: '18', + }, + }, + timestamp: '1625247600', + transaction: { + id: 'tx1', + from: '0xUser1', + }, + }, + { + __typename: 'TradeVaultBalanceChange', + amount: '1500', + old_vault_balance: '4000', + new_vault_balance: '2500', + vault: { + id: 'vault2', + token: { + id: 'token2', + address: '0xTokenAddress2', + name: 'Token2', + symbol: 'TKN2', + decimals: '18', + }, + }, + timestamp: '1625347600', + transaction: { + id: 'tx2', + from: '0xUser2', + }, + }, + { + __typename: 'Deposit', + amount: '2000', + old_vault_balance: '2500', + new_vault_balance: '4500', + vault: { + id: 'vault3', + token: { + id: 'token3', + address: '0xTokenAddress3', + name: 'Token3', + symbol: 'TKN3', + decimals: '18', + }, + }, + timestamp: '1625447600', + transaction: { + id: 'tx3', + from: '0xUser3', + }, + }, + ]; + + mockIPC((cmd) => { + if (cmd === 'vault_balance_changes_list') { + return mockVaultBalanceChanges; + } + }); + + render(VaultBalanceChangesTable, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => { + const rows = screen.getAllByTestId('bodyRow'); + expect(rows).toHaveLength(3); + }); +}); + +test('it shows the correct data in the table', async () => { + const queryClient = new QueryClient(); + + const mockVaultBalanceChanges: VaultBalanceChange[] = [ + { + __typename: 'Withdrawal', + amount: '1000', + old_vault_balance: '5000', + new_vault_balance: '4000', + vault: { + id: 'vault1', + token: { + id: 'token1', + address: '0xTokenAddress1', + name: 'Token1', + symbol: 'TKN1', + decimals: '4', + }, + }, + timestamp: '1625247600', + transaction: { + id: 'tx1', + from: '0xUser1', + }, + }, + ]; + + mockIPC((cmd) => { + if (cmd === 'vault_balance_changes_list') { + return mockVaultBalanceChanges; + } + }); + + render(VaultBalanceChangesTable, { + props: { id: '100' }, + context: new Map([['$$_queryClient', queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByTestId('vaultBalanceChangesTableDate')).toHaveTextContent( + formatTimestampSecondsAsLocal(BigInt('1625247600')), + ); + expect(screen.getByTestId('vaultBalanceChangesTableFrom')).toHaveTextContent('0xUse...User1'); + expect(screen.getByTestId('vaultBalanceChangesTableTx')).toHaveTextContent('tx1'); + expect(screen.getByTestId('vaultBalanceChangesTableBalanceChange')).toHaveTextContent('1 TKN1'); + expect(screen.getByTestId('vaultBalanceChangesTableBalance')).toHaveTextContent('0.4 TKN1'); + expect(screen.getByTestId('vaultBalanceChangesTableType')).toHaveTextContent('Withdrawal'); + }); +}); diff --git a/tauri-app/src/routes/orders/[id]/+page.svelte b/tauri-app/src/routes/orders/[id]/+page.svelte index ded56672f..23422b7a3 100644 --- a/tauri-app/src/routes/orders/[id]/+page.svelte +++ b/tauri-app/src/routes/orders/[id]/+page.svelte @@ -11,7 +11,7 @@ import Hash from '$lib/components/Hash.svelte'; import { HashType } from '$lib/types/hash'; import { sortBy } from 'lodash'; - import LightweightChartLine from '$lib/components/LightweightChartLine.svelte'; + import LightweightChartLine from '$lib/components/charts/LightweightChartLine.svelte'; import PageContentDetail from '$lib/components/PageContentDetail.svelte'; import CodeMirrorRainlang from '$lib/components/CodeMirrorRainlang.svelte'; import { colorTheme } from '$lib/stores/darkMode'; diff --git a/tauri-app/src/routes/vaults/[id]/+page.svelte b/tauri-app/src/routes/vaults/[id]/+page.svelte index cedf6ea6a..a0dec9207 100644 --- a/tauri-app/src/routes/vaults/[id]/+page.svelte +++ b/tauri-app/src/routes/vaults/[id]/+page.svelte @@ -1,210 +1,9 @@ - - -
- {data?.token.name} -
-
- {#if data && $walletAddressMatchesOrBlank(data.owner)} - - - {/if} -
-
- - {#if data} - - Vault ID - {bigintStringToHex(data.vault_id)} - - - - Owner Address - - - - - - - Token address - - - - - - - Balance - {formatUnits(BigInt(data.balance), Number(data.token.decimals ?? 0))} - {data.token.symbol} - - - - Orders as input - - {#if data.orders_as_input && data.orders_as_input.length > 0} -

- {#each data.orders_as_input as order} - - {/each} -

- {:else} - None - {/if} -
-
- - - Orders as output - - {#if data.orders_as_output && data.orders_as_output.length > 0} -

- {#each data.orders_as_output as order} - - {/each} -

- {:else} - None{/if} -
-
- {/if} -
- - - {#if data} - - {/if} - - - - - - Vault Balance Changes - - - Date - Sender - Transaction Hash - Balance Change - Balance - Type - - - - - {formatTimestampSecondsAsLocal(BigInt(item.timestamp))} - - - - - - - - - {formatUnits(BigInt(item.amount), Number(item.vault.token.decimals ?? 0))} - {item.vault.token.symbol} - - - {formatUnits(BigInt(item.new_vault_balance), Number(item.vault.token.decimals ?? 0))} - {item.vault.token.symbol} - - - {item.__typename} - - - - -
+