From fcf55d82543520145ea895fc0931f8c550d0c034 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 14:31:08 +0300 Subject: [PATCH 01/27] update select token component to display token dropdown --- .../components/deployment/SelectToken.svelte | 249 +++++++++++++----- .../deployment/TokenDropdown.svelte | 115 ++++++++ 2 files changed, 304 insertions(+), 60 deletions(-) create mode 100644 packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte diff --git a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index 1abe3e5459..dae6f07799 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -5,31 +5,117 @@ import { Spinner } from 'flowbite-svelte'; import { onMount } from 'svelte'; import { useGui } from '$lib/hooks/useGui'; + import ButtonSelectOption from './ButtonSelectOption.svelte'; + import TokenDropdown from './TokenDropdown.svelte'; export let token: GuiSelectTokensCfg; export let onSelectTokenSelect: () => void; + export let availableTokens: TokenInfo[] = []; + export let loading: boolean = false; + let inputValue: string | null = null; let tokenInfo: TokenInfo | null = null; let error = ''; let checking = false; + let selectionMode: 'dropdown' | 'custom' = 'dropdown'; + let searchQuery: string = ''; + let selectedToken: TokenInfo | null = null; const gui = useGui(); onMount(async () => { - try { - let result = await gui.getTokenInfo(token.key); - if (result.error) { - throw new Error(result.error.msg); - } - tokenInfo = result.value; - if (tokenInfo?.address) { - inputValue = tokenInfo.address; - } - } catch { - // do nothing + let result = await gui.getTokenInfo(token.key); + if (result.error) { + throw new Error(result.error.msg); + } + tokenInfo = result.value; + if (result.value.address) { + inputValue = result.value.address; } }); + $: if (tokenInfo?.address && availableTokens.length > 0) { + const foundToken = availableTokens.find( + (t) => t.address.toLowerCase() === tokenInfo?.address.toLowerCase() + ); + selectedToken = foundToken || null; + } + + $: if (availableTokens.length > 0 && tokenInfo?.address && selectionMode === 'dropdown') { + const foundToken = availableTokens.find( + (t) => t.address.toLowerCase() === tokenInfo?.address.toLowerCase() + ); + if (!foundToken) { + selectionMode = 'custom'; + } + } + + $: if (tokenInfo?.address && inputValue === null) { + inputValue = tokenInfo.address; + } + + function setMode(mode: 'dropdown' | 'custom') { + selectionMode = mode; + error = ''; + + if (mode === 'dropdown') { + searchQuery = ''; + if (inputValue && tokenInfo) { + const foundToken = availableTokens.find( + (t) => t.address.toLowerCase() === inputValue?.toLowerCase() + ); + if (foundToken) { + selectedToken = foundToken; + } else { + inputValue = null; + tokenInfo = null; + selectedToken = null; + clearTokenSelection(); + } + } else { + inputValue = null; + tokenInfo = null; + selectedToken = null; + } + } else if (mode === 'custom') { + selectedToken = null; + tokenInfo = null; + inputValue = ''; + error = ''; + clearTokenSelection(); + } + } + + function handleTokenSelect(token: TokenInfo) { + selectedToken = token; + inputValue = token.address; + saveTokenSelection(token.address); + } + + function handleSearch(query: string) { + searchQuery = query; + } + + async function saveTokenSelection(address: string) { + checking = true; + error = ''; + try { + await gui.saveSelectToken(token.key, address); + await getInfoForSelectedToken(); + } catch (e) { + const errorMessage = (e as Error).message || 'Invalid token address.'; + error = errorMessage; + } finally { + checking = false; + onSelectTokenSelect(); + } + } + + function clearTokenSelection() { + gui.removeSelectToken(token.key); + onSelectTokenSelect(); + } + async function getInfoForSelectedToken() { error = ''; try { @@ -45,65 +131,108 @@ } async function handleInput(event: Event) { - tokenInfo = null; const currentTarget = event.currentTarget; if (currentTarget instanceof HTMLInputElement) { inputValue = currentTarget.value; + + if (tokenInfo && tokenInfo.address.toLowerCase() !== inputValue.toLowerCase()) { + tokenInfo = null; + selectedToken = null; + } + if (!inputValue) { error = ''; + tokenInfo = null; + selectedToken = null; + return; } - checking = true; - try { - await gui.saveSelectToken(token.key, currentTarget.value); - await getInfoForSelectedToken(); - } catch (e) { - const errorMessage = (e as Error).message ? (e as Error).message : 'Invalid token address.'; - error = errorMessage; - } - } - checking = false; - onSelectTokenSelect(); + saveTokenSelection(inputValue); + } } -
-
-
- {#if token.name || token.description} -
- {#if token.name} -

- {token.name} -

- {/if} - {#if token.description} -

- {token.description} -

- {/if} -
- {/if} - {#if checking} -
- - Checking... -
- {:else if tokenInfo} -
- - {tokenInfo.name} -
- {:else if error} -
- - {error} -
- {/if} +
+
+ {#if token.name || token.description} +
+ {#if token.name} +

+ {token.name} +

+ {/if} + {#if token.description} +

+ {token.description} +

+ {/if} +
+ {/if} +
+ + {#if availableTokens.length > 0 && !loading} +
+ setMode('dropdown')} + dataTestId="dropdown-mode-button" + /> + setMode('custom')} + dataTestId="custom-mode-button" + /> +
+ {/if} + + {#if selectionMode === 'dropdown' && availableTokens.length > 0} + + {/if} + + {#if selectionMode === 'custom' || availableTokens.length === 0} +
+
- + {/if} + +
+ {#if loading} +
+ + Loading tokens... +
+ {:else if checking} +
+ + Checking... +
+ {:else if tokenInfo} +
+ + {tokenInfo.name} +
+ {:else if error} +
+ + {error} +
+ {/if}
diff --git a/packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte b/packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte new file mode 100644 index 0000000000..a4a9074e18 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte @@ -0,0 +1,115 @@ + + +
+
+ + + +
+
+ +
+ +
+ +
+ {#each filteredTokens as token (token.address)} +
handleTokenSelect(token)} + on:keydown={(e) => e.key === 'Enter' && handleTokenSelect(token)} + role="button" + tabindex="0" + > +
+
+ {token.name} +
+
+ {token.symbol} + {formatAddress(token.address)} +
+
+ {#if selectedToken?.address === token.address} + + {/if} +
+ {/each} + + {#if filteredTokens.length === 0} +
+

No tokens found matching your search.

+ +
+ {/if} +
+
+
+
From 3298991369341051499ef437822f01ad8705a252 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 14:31:25 +0300 Subject: [PATCH 02/27] update deployment steps --- .../deployment/DeploymentSteps.svelte | 23 ++++++++++++++++++- .../src/lib/errors/DeploymentStepsError.ts | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte index 63b917a4f0..5916de6cd4 100644 --- a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte +++ b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte @@ -55,6 +55,8 @@ let selectTokens: GuiSelectTokensCfg[] | undefined = undefined; let checkingDeployment: boolean = false; let subgraphUrl: string | undefined = undefined; + let availableTokens: TokenInfo[] = []; + let loadingTokens: boolean = false; const gui = useGui(); const registry = useRegistry(); @@ -73,9 +75,28 @@ } else if (value) { subgraphUrl = $settings?.subgraphs?.[value]; } + await loadAvailableTokens(); await areAllTokensSelected(); }); + async function loadAvailableTokens() { + if (loadingTokens) return; + + loadingTokens = true; + try { + const result = await gui.getAllTokens(); + if (result.error) { + throw new Error(result.error.msg); + } + availableTokens = result.value; + } catch (error) { + DeploymentStepsError.catch(error, DeploymentStepsErrorCode.NO_AVAILABLE_TOKENS); + availableTokens = []; + } finally { + loadingTokens = false; + } + } + function getAllFieldDefinitions() { try { const allFieldDefinitionsResult = gui.getAllFieldDefinitions(false); @@ -248,7 +269,7 @@ description="Select the tokens that you want to use in your order." /> {#each selectTokens as token} - + {/each}
{/if} diff --git a/packages/ui-components/src/lib/errors/DeploymentStepsError.ts b/packages/ui-components/src/lib/errors/DeploymentStepsError.ts index 9205220259..c46145b4a2 100644 --- a/packages/ui-components/src/lib/errors/DeploymentStepsError.ts +++ b/packages/ui-components/src/lib/errors/DeploymentStepsError.ts @@ -13,6 +13,7 @@ export enum DeploymentStepsErrorCode { NO_GUI_DETAILS = 'Error getting GUI details', NO_CHAIN = 'Unsupported chain ID', NO_NETWORK_KEY = 'No network key found', + NO_AVAILABLE_TOKENS = 'Error loading available tokens', SERIALIZE_ERROR = 'Error serializing state', ADD_ORDER_FAILED = 'Failed to add order', NO_WALLET = 'No account address found' From 92fa80aa99858b9303a25c9b351dec0aec0f2ffa Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 14:52:54 +0300 Subject: [PATCH 03/27] add token dropdown test --- .../src/__tests__/TokenDropdown.test.ts | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 packages/ui-components/src/__tests__/TokenDropdown.test.ts diff --git a/packages/ui-components/src/__tests__/TokenDropdown.test.ts b/packages/ui-components/src/__tests__/TokenDropdown.test.ts new file mode 100644 index 0000000000..84b14ccb96 --- /dev/null +++ b/packages/ui-components/src/__tests__/TokenDropdown.test.ts @@ -0,0 +1,351 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TokenDropdown from '../lib/components/deployment/TokenDropdown.svelte'; +import type { ComponentProps } from 'svelte'; +import type { TokenInfo } from '@rainlanguage/orderbook'; + +type TokenDropdownProps = ComponentProps; + +const mockTokens: TokenInfo[] = [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + }, + { + address: '0x0987654321098765432109876543210987654321', + name: 'Another Token', + symbol: 'ANOTHER', + decimals: 6 + }, + { + address: '0x1111222233334444555566667777888899990000', + name: 'Third Token', + symbol: 'THIRD', + decimals: 18 + } +]; + +describe('TokenDropdown', () => { + let mockOnSelect: ReturnType; + let mockOnSearch: ReturnType; + + const defaultProps: TokenDropdownProps = { + tokens: mockTokens, + selectedToken: null, + onSelect: vi.fn(), + searchValue: '', + onSearch: vi.fn() + }; + + beforeEach(() => { + mockOnSelect = vi.fn(); + mockOnSearch = vi.fn(); + vi.clearAllMocks(); + }); + + it('renders dropdown button with default text when no token is selected', () => { + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + expect(screen.getByText('Select a token...')).toBeInTheDocument(); + }); + + it('renders dropdown button with selected token info when token is selected', () => { + const selectedToken = mockTokens[0]; + render(TokenDropdown, { + ...defaultProps, + selectedToken, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + expect(screen.getByText('Test Token 1 (TEST1)')).toBeInTheDocument(); + }); + + it('opens dropdown when button is clicked', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByPlaceholderText('Search tokens...')).toBeInTheDocument(); + }); + + it('displays all tokens in the dropdown list', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('Test Token 1')).toBeInTheDocument(); + expect(screen.getByText('TEST1')).toBeInTheDocument(); + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.getByText('ANOTHER')).toBeInTheDocument(); + expect(screen.getByText('Third Token')).toBeInTheDocument(); + expect(screen.getByText('THIRD')).toBeInTheDocument(); + }); + + it('displays formatted addresses in token list', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('0x1234...7890')).toBeInTheDocument(); + expect(screen.getByText('0x0987...4321')).toBeInTheDocument(); + expect(screen.getByText('0x1111...0000')).toBeInTheDocument(); + }); + + it('highlights selected token in the list', async () => { + const user = userEvent.setup(); + const selectedToken = mockTokens[1]; + render(TokenDropdown, { + ...defaultProps, + selectedToken, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const selectedTokenItem = screen.getByText('Another Token').closest('[role="button"]'); + expect(selectedTokenItem).toHaveClass('bg-blue-50'); + + expect(screen.getByRole('img', { name: /check circle solid/i })).toBeInTheDocument(); + }); + + it('calls onSelect when token is clicked', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const firstToken = screen.getByText('Test Token 1').closest('[role="button"]'); + expect(firstToken).toBeInTheDocument(); + + if (firstToken) { + await user.click(firstToken); + } + + expect(mockOnSelect).toHaveBeenCalledWith(mockTokens[0]); + }); + + it('closes dropdown after token selection', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByPlaceholderText('Search tokens...')).toBeInTheDocument(); + + const firstToken = screen.getByText('Test Token 1').closest('[role="button"]'); + if (firstToken) { + await user.click(firstToken); + } + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Search tokens...')).not.toBeInTheDocument(); + }); + }); + + it('filters tokens based on search input', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'test', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('Test Token 1')).toBeInTheDocument(); + expect(screen.queryByText('Another Token')).not.toBeInTheDocument(); + expect(screen.queryByText('Third Token')).not.toBeInTheDocument(); + }); + + it('calls onSearch when search input changes', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const searchInput = screen.getByPlaceholderText('Search tokens...'); + await user.type(searchInput, 'another'); + + expect(mockOnSearch).toHaveBeenCalledWith('another'); + }); + + it('filters tokens by symbol', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'ANOTHER', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.queryByText('Test Token 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Third Token')).not.toBeInTheDocument(); + }); + + it('filters tokens by address', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: '0x1234', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('Test Token 1')).toBeInTheDocument(); + expect(screen.queryByText('Another Token')).not.toBeInTheDocument(); + expect(screen.queryByText('Third Token')).not.toBeInTheDocument(); + }); + + it('shows "no results" message when no tokens match search', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'nonexistent', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('No tokens found matching your search.')).toBeInTheDocument(); + expect(screen.getByText('Clear search')).toBeInTheDocument(); + }); + + it('clears search when "Clear search" button is clicked', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'nonexistent', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const clearButton = screen.getByText('Clear search'); + await user.click(clearButton); + + expect(mockOnSearch).toHaveBeenCalledWith(''); + }); + + it('handles token selection via keyboard (Enter key)', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const firstToken = screen.getByText('Test Token 1').closest('[role="button"]') as HTMLElement; + if (firstToken) { + firstToken.focus(); + await user.keyboard('{Enter}'); + } + + expect(mockOnSelect).toHaveBeenCalledWith(mockTokens[0]); + }); + + it('displays empty state when no tokens are provided', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + tokens: [], + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('No tokens found matching your search.')).toBeInTheDocument(); + }); + + it('maintains search value in input field', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'initial search', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + const searchInput = screen.getByPlaceholderText('Search tokens...') as HTMLInputElement; + expect(searchInput.value).toBe('initial search'); + }); + + it('search is case insensitive', async () => { + const user = userEvent.setup(); + render(TokenDropdown, { + ...defaultProps, + searchValue: 'TEST', + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByText('Test Token 1')).toBeInTheDocument(); + expect(screen.queryByText('Another Token')).not.toBeInTheDocument(); + }); +}); From 3a2e94f1f466f72ff5466e0d643e20aefc4adaf1 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 15:08:54 +0300 Subject: [PATCH 04/27] update tests --- .../src/__tests__/DeploymentSteps.test.ts | 206 +++++++++++++++++- .../components/deployment/SelectToken.svelte | 18 +- 2 files changed, 211 insertions(+), 13 deletions(-) diff --git a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts index 185c64bd6d..f8edab22c7 100644 --- a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts @@ -111,15 +111,39 @@ describe('DeploymentSteps', () => { hasAnyDeposit: vi.fn().mockReturnValue({ value: false }), hasAnyVaultId: vi.fn().mockReturnValue(false), getAllTokenInfos: vi.fn().mockResolvedValue({ value: [] }), + getAllTokens: vi.fn().mockResolvedValue({ + value: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + }, + { + address: '0x0987654321098765432109876543210987654321', + name: 'Another Token', + symbol: 'ANOTHER', + decimals: 6 + } + ] + }), getCurrentDeploymentDetails: vi.fn().mockReturnValue({ value: { name: 'Test Deployment', description: 'This is a test deployment description' } }), - getTokenInfo: vi.fn(), + getTokenInfo: vi.fn().mockResolvedValue({ + value: { + name: 'Test Token', + symbol: 'TEST', + address: '0x123', + decimals: 18 + } + }), isSelectTokenSet: vi.fn().mockReturnValue({ value: false }), saveSelectToken: vi.fn(), + removeSelectToken: vi.fn(), getDeploymentTransactionArgs: vi.fn() } as unknown as DotrainOrderGui; @@ -306,7 +330,9 @@ describe('DeploymentSteps', () => { props: defaultProps }); - expect(mockGui.areAllTokensSelected).toHaveBeenCalled(); + await waitFor(() => { + expect(mockGui.areAllTokensSelected).toHaveBeenCalled(); + }); await waitFor(() => { expect(screen.getByText('Select Tokens')).toBeInTheDocument(); @@ -314,7 +340,7 @@ describe('DeploymentSteps', () => { expect(screen.getByText('Token 2')).toBeInTheDocument(); }); - let selectTokenInput = screen.getAllByRole('textbox')[0]; + const selectTokenInput = screen.getByText('Token 1'); (mockGui.getTokenInfo as Mock).mockResolvedValue({ value: { address: '0x1', @@ -325,7 +351,7 @@ describe('DeploymentSteps', () => { }); await user.type(selectTokenInput, '0x1'); - const selectTokenOutput = screen.getAllByRole('textbox')[1]; + const selectTokenOutput = screen.getByText('Token 2'); (mockGui.getTokenInfo as Mock).mockResolvedValue({ value: { address: '0x2', @@ -340,7 +366,10 @@ describe('DeploymentSteps', () => { expect(mockGui.getAllTokenInfos).toHaveBeenCalled(); }); - selectTokenInput = screen.getAllByRole('textbox')[0]; + const customAddressButtons = screen.getAllByTestId('custom-mode-button'); + await user.click(customAddressButtons[customAddressButtons.length - 1]); + const customInputs = screen.getAllByPlaceholderText('Enter token address (0x...)'); + const lastCustomInput = customInputs[customInputs.length - 1]; (mockGui.getTokenInfo as Mock).mockResolvedValue({ value: { address: '0x3', @@ -349,7 +378,7 @@ describe('DeploymentSteps', () => { symbol: 'TKN3' } }); - await user.type(selectTokenInput, '0x3'); + await user.type(lastCustomInput, '0x3'); (mockGui.getAllTokenInfos as Mock).mockResolvedValue({ value: [ @@ -399,4 +428,169 @@ describe('DeploymentSteps', () => { expect(subgraphUrlArg).toBe(expectedSubgraphUrl); }); }); + + // New tests for loadAvailableTokens functionality + describe('loadAvailableTokens functionality', () => { + it('loads available tokens on mount', async () => { + render(DeploymentSteps, { props: defaultProps }); + + await waitFor(() => { + expect(mockGui.getAllTokens).toHaveBeenCalled(); + }); + }); + + it('passes available tokens to SelectToken components', async () => { + const mockSelectTokens = [ + { key: 'token1', name: 'Token 1', description: undefined }, + { key: 'token2', name: 'Token 2', description: undefined } + ]; + + (mockGui.getSelectTokens as Mock).mockReturnValue({ + value: mockSelectTokens + }); + + render(DeploymentSteps, { props: defaultProps }); + + await waitFor(() => { + // Should pass availableTokens and loading props to SelectToken + expect(screen.getByText('Select Tokens')).toBeInTheDocument(); + }); + + // The SelectToken components should receive the available tokens + // This is tested indirectly through the component rendering + }); + + it('handles error when loading available tokens fails', async () => { + (mockGui.getAllTokens as Mock).mockResolvedValue({ + error: { msg: 'Failed to load tokens' } + }); + + render(DeploymentSteps, { props: defaultProps }); + + await waitFor(() => { + expect(mockGui.getAllTokens).toHaveBeenCalled(); + }); + + // Should handle the error gracefully and continue rendering + expect(screen.queryByText('SFLR<>WFLR on Flare')).toBeInTheDocument(); + }); + + it('shows loading state while tokens are being loaded', async () => { + const mockSelectTokens = [{ key: 'token1', name: 'Token 1', description: undefined }]; + + (mockGui.getSelectTokens as Mock).mockReturnValue({ + value: mockSelectTokens + }); + + // Mock a slow getAllTokens response + let resolveTokens: (value: unknown) => void = () => {}; + const tokenPromise = new Promise((resolve) => { + resolveTokens = resolve; + }); + (mockGui.getAllTokens as Mock).mockReturnValue(tokenPromise); + + render(DeploymentSteps, { props: defaultProps }); + + // Should show loading state initially + await waitFor(() => { + expect(screen.getByText('Loading tokens...')).toBeInTheDocument(); + }); + + // Resolve the promise + resolveTokens({ + value: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + } + ] + }); + + // Loading state should disappear + await waitFor(() => { + expect(screen.queryByText('Loading tokens...')).not.toBeInTheDocument(); + }); + }); + + it('prevents multiple simultaneous token loading requests', async () => { + // Mock getAllTokens to return a promise that doesn't resolve immediately + const tokenPromise = new Promise((resolve) => { + setTimeout( + () => + resolve({ + value: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + } + ] + }), + 50 + ); + }); + (mockGui.getAllTokens as Mock).mockReturnValue(tokenPromise); + + render(DeploymentSteps, { props: defaultProps }); + + // Wait for the first call + await waitFor(() => { + expect(mockGui.getAllTokens).toHaveBeenCalledTimes(1); + }); + + // Even if component re-renders while loading, shouldn't call getAllTokens again + // This is handled by the loadingTokens guard in the component + await waitFor( + () => { + expect(mockGui.getAllTokens).toHaveBeenCalledTimes(1); + }, + { timeout: 100 } + ); + }); + + it('sets availableTokens to empty array when loading fails', async () => { + const mockSelectTokens = [{ key: 'token1', name: 'Token 1', description: undefined }]; + + (mockGui.getSelectTokens as Mock).mockReturnValue({ + value: mockSelectTokens + }); + + (mockGui.getAllTokens as Mock).mockRejectedValue(new Error('Network error')); + + render(DeploymentSteps, { props: defaultProps }); + + await waitFor(() => { + expect(mockGui.getAllTokens).toHaveBeenCalled(); + }); + + // Component should still render successfully + expect(screen.getByText('Select Tokens')).toBeInTheDocument(); + // SelectToken should receive empty availableTokens array + // This would result in custom input mode being shown + }); + + it('handles getAllTokens returning error in result', async () => { + const mockSelectTokens = [{ key: 'token1', name: 'Token 1', description: undefined }]; + + (mockGui.getSelectTokens as Mock).mockReturnValue({ + value: mockSelectTokens + }); + + (mockGui.getAllTokens as Mock).mockResolvedValue({ + error: { msg: 'API error' } + }); + + render(DeploymentSteps, { props: defaultProps }); + + await waitFor(() => { + expect(mockGui.getAllTokens).toHaveBeenCalled(); + }); + + // Component should handle the error case gracefully + expect(screen.getByText('Select Tokens')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index dae6f07799..e90a081794 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -24,13 +24,17 @@ const gui = useGui(); onMount(async () => { - let result = await gui.getTokenInfo(token.key); - if (result.error) { - throw new Error(result.error.msg); - } - tokenInfo = result.value; - if (result.value.address) { - inputValue = result.value.address; + try { + let result = await gui.getTokenInfo(token.key); + if (result.error) { + throw new Error(result.error.msg); + } + tokenInfo = result.value; + if (result.value.address) { + inputValue = result.value.address; + } + } catch { + // do nothing } }); From 55b27dbf5fbfa8e023e5ec3360c5cd03794e5d69 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 15:36:42 +0300 Subject: [PATCH 05/27] update tests --- .../src/__tests__/SelectToken.test.ts | 265 ++++++++++++++++-- .../components/deployment/SelectToken.svelte | 2 +- 2 files changed, 244 insertions(+), 23 deletions(-) diff --git a/packages/ui-components/src/__tests__/SelectToken.test.ts b/packages/ui-components/src/__tests__/SelectToken.test.ts index 6d6f84c9c5..e489e2b463 100644 --- a/packages/ui-components/src/__tests__/SelectToken.test.ts +++ b/packages/ui-components/src/__tests__/SelectToken.test.ts @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/svelte'; +import { render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import SelectToken from '../lib/components/deployment/SelectToken.svelte'; @@ -11,10 +11,14 @@ type SelectTokenComponentProps = ComponentProps; const mockGui: DotrainOrderGui = { saveSelectToken: vi.fn(), isSelectTokenSet: vi.fn(), + removeSelectToken: vi.fn(), getTokenInfo: vi.fn().mockResolvedValue({ - symbol: 'ETH', - decimals: 18, - address: '0x456' + value: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + address: '0x456' + } }) } as unknown as DotrainOrderGui; @@ -25,13 +29,30 @@ vi.mock('../lib/hooks/useGui', () => ({ describe('SelectToken', () => { let mockStateUpdateCallback: Mock; + const mockTokens = [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + }, + { + address: '0x0987654321098765432109876543210987654321', + name: 'Another Token', + symbol: 'ANOTHER', + decimals: 6 + } + ]; + const mockProps: SelectTokenComponentProps = { token: { key: 'input', name: 'test input', description: 'test description' }, - onSelectTokenSelect: vi.fn() + onSelectTokenSelect: vi.fn(), + availableTokens: mockTokens, + loading: false }; beforeEach(() => { @@ -40,6 +61,7 @@ describe('SelectToken', () => { mockStateUpdateCallback(); return Promise.resolve(); }); + (useGui as Mock).mockReturnValue(mockGui); vi.clearAllMocks(); }); @@ -48,30 +70,34 @@ describe('SelectToken', () => { expect(getByText('test input')).toBeInTheDocument(); }); - it('renders input field', () => { - const { getByRole } = render(SelectToken, mockProps); - expect(getByRole('textbox')).toBeInTheDocument(); + it('renders dropdown button when tokens are available', () => { + const { getByText } = render(SelectToken, mockProps); + expect(getByText('Select a token...')).toBeInTheDocument(); }); - it('calls saveSelectToken and updates token info when input changes', async () => { + it('calls saveSelectToken and updates token info when custom input changes', async () => { const user = userEvent.setup(); const mockGuiWithNoToken = { ...mockGui, - getTokenInfo: vi.fn().mockResolvedValue(null) + getTokenInfo: vi.fn().mockResolvedValue({ value: null }) } as unknown as DotrainOrderGui; (useGui as Mock).mockReturnValue(mockGuiWithNoToken); - const { getByRole } = render(SelectToken, { + const { getByTestId, getByRole } = render(SelectToken, { ...mockProps }); + + const customButton = getByTestId('custom-mode-button'); + await user.click(customButton); + const input = getByRole('textbox'); await userEvent.clear(input); await user.paste('0x456'); await waitFor(() => { - expect(mockGui.saveSelectToken).toHaveBeenCalledWith('input', '0x456'); + expect(mockGuiWithNoToken.saveSelectToken).toHaveBeenCalledWith('input', '0x456'); }); expect(mockStateUpdateCallback).toHaveBeenCalledTimes(1); }); @@ -89,6 +115,9 @@ describe('SelectToken', () => { ...mockProps }); + const customButton = screen.getByTestId('custom-mode-button'); + await user.click(customButton); + const input = screen.getByRole('textbox'); await userEvent.clear(input); await user.paste('invalid'); @@ -99,17 +128,21 @@ describe('SelectToken', () => { it('does nothing if gui is not defined', async () => { const user = userEvent.setup(); - const { getByRole } = render(SelectToken, { + (useGui as Mock).mockReturnValue(null); + + const { queryByRole } = render(SelectToken, { ...mockProps, - gui: undefined - } as unknown as SelectTokenComponentProps); - const input = getByRole('textbox'); + availableTokens: [] + }); - await userEvent.clear(input); - await user.paste('0x456'); + const input = queryByRole('textbox'); + if (input) { + await userEvent.clear(input); + await user.paste('0x456'); + } await waitFor(() => { - expect(mockGui.saveSelectToken).not.toHaveBeenCalled(); + expect(input).toBeInTheDocument(); }); }); @@ -123,22 +156,29 @@ describe('SelectToken', () => { const user = userEvent.setup(); - const { getByRole } = render(SelectToken, { + const { getByRole, getByTestId } = render(SelectToken, { ...mockProps }); + const customButton = getByTestId('custom-mode-button'); + await user.click(customButton); + const input = getByRole('textbox'); await userEvent.clear(input); await user.paste('invalid'); await waitFor(() => { - expect(mockGui.saveSelectToken).toHaveBeenCalled(); + expect(mockGuiWithTokenSet.saveSelectToken).toHaveBeenCalled(); expect(mockStateUpdateCallback).toHaveBeenCalledTimes(1); }); }); it('calls onSelectTokenSelect after input changes', async () => { const user = userEvent.setup(); - const { getByRole } = render(SelectToken, mockProps); + const { getByRole, getByTestId } = render(SelectToken, mockProps); + + const customButton = getByTestId('custom-mode-button'); + await user.click(customButton); + const input = getByRole('textbox'); await userEvent.clear(input); @@ -148,4 +188,185 @@ describe('SelectToken', () => { expect(mockProps.onSelectTokenSelect).toHaveBeenCalled(); }); }); + + it('switches to custom mode automatically if selected token is not in available tokens', async () => { + mockGui.getTokenInfo = vi.fn().mockResolvedValue({ + value: { + name: 'Custom Token', + symbol: 'CUSTOM', + address: '0xCustomTokenAddress', + decimals: 18 + } + }); + + render(SelectToken, mockProps); + + await waitFor(() => { + expect(screen.queryByText('Select a token...')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter token address (0x...)')).toBeInTheDocument(); + }); + }); + + describe.only('Dropdown Mode', () => { + beforeEach(() => { + (useGui as Mock).mockReturnValue(mockGui); + }); + + it('shows dropdown and custom mode buttons when tokens are available', () => { + render(SelectToken, mockProps); + + expect(screen.getByTestId('dropdown-mode-button')).toBeInTheDocument(); + expect(screen.getByTestId('custom-mode-button')).toBeInTheDocument(); + }); + + it('shows dropdown mode as active by default', () => { + render(SelectToken, mockProps); + + const dropdownButton = screen.getByTestId('dropdown-mode-button'); + const customButton = screen.getByTestId('custom-mode-button'); + + expect(dropdownButton).toHaveClass('border-blue-300'); + expect(customButton).not.toHaveClass('border-blue-300'); + }); + + it('switches to custom mode when custom button is clicked', async () => { + const user = userEvent.setup(); + render(SelectToken, mockProps); + + const customButton = screen.getByTestId('custom-mode-button'); + await user.click(customButton); + + expect(customButton).toHaveClass('border-blue-300'); + expect(screen.getByTestId('dropdown-mode-button')).not.toHaveClass('border-blue-300'); + }); + + it('shows TokenDropdown component in dropdown mode', () => { + render(SelectToken, mockProps); + + expect(screen.getByText('Select a token...')).toBeInTheDocument(); + }); + + it('shows custom input in custom mode', async () => { + const user = userEvent.setup(); + render(SelectToken, mockProps); + + const customButton = screen.getByTestId('custom-mode-button'); + await user.click(customButton); + + expect(screen.getByPlaceholderText('Enter token address (0x...)')).toBeInTheDocument(); + }); + + it('clears state when switching from dropdown to custom mode', async () => { + const user = userEvent.setup(); + render(SelectToken, { + ...mockProps, + availableTokens: [ + { + address: '0x456', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + } + ] + }); + + const dropdownButton = screen.getByText('Select a token...'); + await user.click(dropdownButton); + + const firstToken = screen.getByText('Test Token 1'); + await user.click(firstToken); + + const customButton = screen.getByTestId('custom-mode-button'); + await user.click(customButton); + + const customInput = screen.getByPlaceholderText('Enter token address (0x...)'); + expect(customInput).toHaveValue(''); + + expect(mockGui.removeSelectToken).toHaveBeenCalledWith('input'); + }); + + it('clears state when switching from custom to dropdown mode', async () => { + const user = userEvent.setup(); + render(SelectToken, mockProps); + + const customButton = screen.getByTestId('custom-mode-button'); + await user.click(customButton); + + const customInput = screen.getByPlaceholderText('Enter token address (0x...)'); + await user.type(customInput, '0x1234567890123456789012345678901234567890'); + + const dropdownButton = screen.getByTestId('dropdown-mode-button'); + await user.click(dropdownButton); + + expect(mockGui.removeSelectToken).toHaveBeenCalledWith('input'); + }); + + it('handles token selection from dropdown', async () => { + const user = userEvent.setup(); + render(SelectToken, { + ...mockProps, + availableTokens: [ + { + address: '0x456', + name: 'Test Token 1', + symbol: 'TEST1', + decimals: 18 + }, + { + address: '0x789', + name: 'Test Token 2', + symbol: 'TEST2', + decimals: 18 + } + ] + }); + + const dropdownButton = screen.getByText('Select a token...'); + await user.click(dropdownButton); + + const secondToken = screen.getByText('Test Token 2'); + await user.click(secondToken); + + expect(mockGui.saveSelectToken).toHaveBeenCalledWith('input', '0x789'); + }); + + it('shows loading state when tokens are loading', () => { + render(SelectToken, { + ...mockProps, + loading: true + }); + + expect(screen.getByText('Loading tokens...')).toBeInTheDocument(); + }); + + it('defaults to custom mode when no tokens are available', () => { + render(SelectToken, { + ...mockProps, + availableTokens: [] + }); + + expect(screen.getByPlaceholderText('Enter token address (0x...)')).toBeInTheDocument(); + expect(screen.queryByTestId('dropdown-mode-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('custom-mode-button')).not.toBeInTheDocument(); + }); + + it('displays selected token info when token is selected', async () => { + mockGui.getTokenInfo = vi.fn().mockResolvedValue({ + value: { + name: 'Test Token 1', + symbol: 'TEST1', + address: '0x1234567890123456789012345678901234567890', + decimals: 18 + } + }); + + render(SelectToken, mockProps); + + await waitFor(() => { + expect(screen.getByText('Test Token 1')).toBeInTheDocument(); + }); + + expect(screen.getByTestId(`select-token-success-${mockProps.token.key}`)).toBeInTheDocument(); + }); + }); }); diff --git a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index e90a081794..ac1f8ee5c4 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -30,7 +30,7 @@ throw new Error(result.error.msg); } tokenInfo = result.value; - if (result.value.address) { + if (result.value?.address) { inputValue = result.value.address; } } catch { From 3e723cc1ac198d9766d559e50ede275aa85d81ec Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 16:10:07 +0300 Subject: [PATCH 06/27] update deployment tests --- .../[deploymentKey]/fullDeployment.test.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts b/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts index f1a3c23985..03284c8624 100644 --- a/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts +++ b/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts @@ -266,22 +266,20 @@ describe('Full Deployment Tests', () => { expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); }); - // Get all the current input elements for select tokens - const selectTokenInputs = screen.getAllByRole('textbox') as HTMLInputElement[]; - - const sellTokenInput = selectTokenInputs[0]; - const buyTokenInput = selectTokenInputs[1]; + // Check that the token dropdowns are present + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /chevron down solid/i }).length).toBe(2); + }); + const tokenDropdownButtons = screen.getAllByRole('button', { name: /chevron down solid/i }); - // Select the sell token - await userEvent.clear(sellTokenInput); - await userEvent.type(sellTokenInput, '0x12e605bc104e93B45e1aD99F9e555f659051c2BB'); + await userEvent.click(tokenDropdownButtons[0]); + await userEvent.click(screen.getByText('Staked FLR')); await waitFor(() => { expect(screen.getByTestId('select-token-success-output')).toBeInTheDocument(); }); - // Select the buy token - await userEvent.clear(buyTokenInput); - await userEvent.type(buyTokenInput, '0x1D80c49BbBCd1C0911346656B529DF9E5c2F783d'); + await userEvent.click(tokenDropdownButtons[1]); + await userEvent.click(screen.getByText('Wrapped Flare')); await waitFor(() => { expect(screen.getByTestId('select-token-success-input')).toBeInTheDocument(); }); From 820b49e2bb483c2abfc20528f1757132fc7c6174 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 16:21:11 +0300 Subject: [PATCH 07/27] fix remote tokens logic --- crates/settings/src/remote/tokens.rs | 1 + crates/settings/src/remote_tokens.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/settings/src/remote/tokens.rs b/crates/settings/src/remote/tokens.rs index 85253bea88..6511e51e26 100644 --- a/crates/settings/src/remote/tokens.rs +++ b/crates/settings/src/remote/tokens.rs @@ -35,6 +35,7 @@ pub struct Tokens { pub keywords: Vec, pub version: Version, pub tokens: Vec, + #[serde(rename = "logoURI")] pub logo_uri: String, } diff --git a/crates/settings/src/remote_tokens.rs b/crates/settings/src/remote_tokens.rs index e0711b30d5..19b439fd1f 100644 --- a/crates/settings/src/remote_tokens.rs +++ b/crates/settings/src/remote_tokens.rs @@ -220,7 +220,7 @@ using-tokens-from: {} "decimals": 18 } ], - "logoUri": "http://localhost.com" + "logoURI": "http://localhost.com" } "#; server From 5bf388df4242ff0cefa703250b292f5364a3b474 Mon Sep 17 00:00:00 2001 From: findolor Date: Fri, 13 Jun 2025 16:54:59 +0300 Subject: [PATCH 08/27] update registry --- packages/webapp/src/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/src/lib/constants.ts b/packages/webapp/src/lib/constants.ts index 8d1e2069e6..f0a3e22144 100644 --- a/packages/webapp/src/lib/constants.ts +++ b/packages/webapp/src/lib/constants.ts @@ -1,2 +1,2 @@ export const REGISTRY_URL = - 'https://raw.githubusercontent.com/rainlanguage/rain.strategies/0c7e62f4e1d773d2dde6c25720da2a7de446b011/registry'; + 'https://raw.githubusercontent.com/rainlanguage/rain.strategies/20040aa009adc922091c198f3cb6cde5aa457e56/registry'; From 44344c1e9191f381d1bf3649fe7b17064c2b2998 Mon Sep 17 00:00:00 2001 From: findolor Date: Tue, 17 Jun 2025 14:46:32 +0200 Subject: [PATCH 09/27] update --- crates/settings/src/yaml/orderbook.rs | 4 ---- packages/orderbook/test/js_api/gui.test.ts | 10 +++++----- .../src/__tests__/DeploymentSteps.test.ts | 1 - .../src/__tests__/OrdersListTable.test.ts | 1 - .../src/__tests__/SelectToken.test.ts | 20 ++++++++++++++++--- .../src/__tests__/VaultsListTable.test.ts | 4 +--- .../ui-components/src/lib/__mocks__/stores.ts | 14 +++++++------ packages/ui-components/vite.config.ts | 1 + 8 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/settings/src/yaml/orderbook.rs b/crates/settings/src/yaml/orderbook.rs index 893482bfce..d402cbdc71 100644 --- a/crates/settings/src/yaml/orderbook.rs +++ b/crates/settings/src/yaml/orderbook.rs @@ -122,10 +122,6 @@ impl OrderbookYaml { let context = self.initialize_context_and_expand_remote_data()?; TokenCfg::parse_all_from_yaml(self.documents.clone(), Some(&context)) } - pub fn get_tokens(&self) -> Result, YamlError> { - let context = self.initialize_context_and_expand_remote_data()?; - TokenCfg::parse_all_from_yaml(self.documents.clone(), Some(&context)) - } pub fn get_token(&self, key: &str) -> Result { let context = self.initialize_context_and_expand_remote_data()?; TokenCfg::parse_from_yaml(self.documents.clone(), key, Some(&context)) diff --git a/packages/orderbook/test/js_api/gui.test.ts b/packages/orderbook/test/js_api/gui.test.ts index b3b75ef921..7749c25c38 100644 --- a/packages/orderbook/test/js_api/gui.test.ts +++ b/packages/orderbook/test/js_api/gui.test.ts @@ -2031,7 +2031,7 @@ ${dotrainWithoutVaultIds}`; patch: 0 }, tokens: [], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'test-deployment'); @@ -2131,7 +2131,7 @@ ${dotrainWithoutVaultIds}`; patch: 0 }, tokens: [], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'test-deployment'); @@ -2187,7 +2187,7 @@ ${dotrainWithoutVaultIds}`; decimals: 18 } ], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'other-deployment'); @@ -2243,7 +2243,7 @@ ${dotrainWithoutVaultIds}`; decimals: 18 } ], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'other-deployment'); @@ -2297,7 +2297,7 @@ ${dotrainWithoutVaultIds}`; decimals: 18 } ], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const guiResult = await DotrainOrderGui.newWithDeployment( diff --git a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts index 1071e6bf8c..09c6aea7bb 100644 --- a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts @@ -90,7 +90,6 @@ const defaultProps: DeploymentStepsProps = { appKitModal: writable({} as AppKit), onDeploy: mockOnDeploy, settings: writable(mockConfig), - registryUrl: 'https://registry.reown.xyz', account: readable('0x123') } as DeploymentStepsProps; diff --git a/packages/ui-components/src/__tests__/OrdersListTable.test.ts b/packages/ui-components/src/__tests__/OrdersListTable.test.ts index 85e4c5526b..310bee8569 100644 --- a/packages/ui-components/src/__tests__/OrdersListTable.test.ts +++ b/packages/ui-components/src/__tests__/OrdersListTable.test.ts @@ -85,7 +85,6 @@ const defaultProps: OrdersListTableProps = { orderHash: mockOrderHashStore, hideZeroBalanceVaults: mockHideZeroBalanceVaultsStore, showMyItemsOnly: mockShowMyItemsOnlyStore, - currentRoute: '/orders', activeNetworkRef: mockActiveNetworkRefStore, activeOrderbookRef: mockActiveOrderbookRefStore } as unknown as OrdersListTableProps; diff --git a/packages/ui-components/src/__tests__/SelectToken.test.ts b/packages/ui-components/src/__tests__/SelectToken.test.ts index e489e2b463..b35cd9d1be 100644 --- a/packages/ui-components/src/__tests__/SelectToken.test.ts +++ b/packages/ui-components/src/__tests__/SelectToken.test.ts @@ -207,7 +207,7 @@ describe('SelectToken', () => { }); }); - describe.only('Dropdown Mode', () => { + describe('Dropdown Mode', () => { beforeEach(() => { (useGui as Mock).mockReturnValue(mockGui); }); @@ -258,6 +258,13 @@ describe('SelectToken', () => { it('clears state when switching from dropdown to custom mode', async () => { const user = userEvent.setup(); + const mockGuiNoToken = { + ...mockGui, + getTokenInfo: vi.fn().mockResolvedValue({ value: null }) + } as unknown as DotrainOrderGui; + + (useGui as Mock).mockReturnValue(mockGuiNoToken); + render(SelectToken, { ...mockProps, availableTokens: [ @@ -282,7 +289,7 @@ describe('SelectToken', () => { const customInput = screen.getByPlaceholderText('Enter token address (0x...)'); expect(customInput).toHaveValue(''); - expect(mockGui.removeSelectToken).toHaveBeenCalledWith('input'); + expect(mockGuiNoToken.removeSelectToken).toHaveBeenCalledWith('input'); }); it('clears state when switching from custom to dropdown mode', async () => { @@ -303,6 +310,13 @@ describe('SelectToken', () => { it('handles token selection from dropdown', async () => { const user = userEvent.setup(); + const mockGuiNoToken = { + ...mockGui, + getTokenInfo: vi.fn().mockResolvedValue({ value: null }) + } as unknown as DotrainOrderGui; + + (useGui as Mock).mockReturnValue(mockGuiNoToken); + render(SelectToken, { ...mockProps, availableTokens: [ @@ -327,7 +341,7 @@ describe('SelectToken', () => { const secondToken = screen.getByText('Test Token 2'); await user.click(secondToken); - expect(mockGui.saveSelectToken).toHaveBeenCalledWith('input', '0x789'); + expect(mockGuiNoToken.saveSelectToken).toHaveBeenCalledWith('input', '0x789'); }); it('shows loading state when tokens are loading', () => { diff --git a/packages/ui-components/src/__tests__/VaultsListTable.test.ts b/packages/ui-components/src/__tests__/VaultsListTable.test.ts index 240284bc96..7f9e22a064 100644 --- a/packages/ui-components/src/__tests__/VaultsListTable.test.ts +++ b/packages/ui-components/src/__tests__/VaultsListTable.test.ts @@ -66,7 +66,6 @@ const { const defaultProps = { activeOrderbook: mockActiveOrderbookRefStore, - subgraphUrl: readable('https://api.thegraph.com/subgraphs/name/test'), orderHash: mockOrderHashStore, accounts: mockAccountsStore, activeAccountsItems: mockActiveAccountsItemsStore, @@ -76,8 +75,7 @@ const defaultProps = { hideZeroBalanceVaults: mockHideZeroBalanceVaultsStore, activeNetworkRef: mockActiveNetworkRefStore, activeOrderbookRef: mockActiveOrderbookRefStore, - activeAccounts: mockActiveAccountsStore, - currentRoute: '/vaults' + activeAccounts: mockActiveAccountsStore }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/ui-components/src/lib/__mocks__/stores.ts b/packages/ui-components/src/lib/__mocks__/stores.ts index 6bc2f75810..f864d3b632 100644 --- a/packages/ui-components/src/lib/__mocks__/stores.ts +++ b/packages/ui-components/src/lib/__mocks__/stores.ts @@ -6,12 +6,14 @@ import settingsYamlContent from '../__fixtures__/settings.yaml?raw'; import { type Config } from '@wagmi/core'; import { mockWeb3Config } from './mockWeb3Config'; -vi.mock(import('@rainlanguage/orderbook'), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual - }; -}); +if (import.meta.vitest) { + vi.mock(import('@rainlanguage/orderbook'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual + }; + }); +} // Parse the YAML settings const parseResult = parseYaml([settingsYamlContent]); diff --git a/packages/ui-components/vite.config.ts b/packages/ui-components/vite.config.ts index ef13532ad8..24335a7605 100644 --- a/packages/ui-components/vite.config.ts +++ b/packages/ui-components/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig(({ mode }) => ({ globals: true, environment: 'jsdom', include: ['src/**/*.{test,spec}.ts'], + exclude: ['src/**/__mocks__/**/*'], // Extend jest-dom matchers setupFiles: ['./test-setup.ts'], // load env vars From 8f21059600284b2783ebbeed77624dcfd7811fab Mon Sep 17 00:00:00 2001 From: findolor Date: Wed, 18 Jun 2025 15:36:16 +0200 Subject: [PATCH 10/27] update --- crates/js_api/src/gui/select_tokens.rs | 10 +++++++--- .../lib/components/deployment/SelectToken.svelte | 16 ++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/js_api/src/gui/select_tokens.rs b/crates/js_api/src/gui/select_tokens.rs index 2a5728355c..2800fdc0f0 100644 --- a/crates/js_api/src/gui/select_tokens.rs +++ b/crates/js_api/src/gui/select_tokens.rs @@ -179,9 +179,13 @@ impl DotrainOrderGui { .await?; results.extend(fetched_results); results.sort_by(|a, b| { - let na = a.name.to_lowercase(); - let nb = b.name.to_lowercase(); - na.cmp(&nb).then_with(|| a.address.cmp(&b.address)) + a.address + .to_string() + .to_lowercase() + .cmp(&b.address.to_string().to_lowercase()) + }); + results.dedup_by(|a, b| { + a.address.to_string().to_lowercase() == b.address.to_string().to_lowercase() }); Ok(results) diff --git a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index ac1f8ee5c4..66c110aa62 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -43,18 +43,14 @@ (t) => t.address.toLowerCase() === tokenInfo?.address.toLowerCase() ); selectedToken = foundToken || null; - } - $: if (availableTokens.length > 0 && tokenInfo?.address && selectionMode === 'dropdown') { - const foundToken = availableTokens.find( - (t) => t.address.toLowerCase() === tokenInfo?.address.toLowerCase() - ); - if (!foundToken) { + if (inputValue === null) { + inputValue = tokenInfo.address; + } + if (!foundToken && selectionMode === 'dropdown') { selectionMode = 'custom'; } - } - - $: if (tokenInfo?.address && inputValue === null) { + } else if (tokenInfo?.address && inputValue === null) { inputValue = tokenInfo.address; } @@ -191,7 +187,7 @@
{/if} - {#if selectionMode === 'dropdown' && availableTokens.length > 0} + {#if selectionMode === 'dropdown' && availableTokens.length > 0 && !loading} Date: Wed, 18 Jun 2025 15:57:00 +0200 Subject: [PATCH 11/27] update select token logic --- crates/js_api/src/gui/select_tokens.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/crates/js_api/src/gui/select_tokens.rs b/crates/js_api/src/gui/select_tokens.rs index 2800fdc0f0..03f34d5b00 100644 --- a/crates/js_api/src/gui/select_tokens.rs +++ b/crates/js_api/src/gui/select_tokens.rs @@ -434,26 +434,18 @@ mod tests { assert_eq!(tokens.len(), 4); assert_eq!( tokens[0].address.to_string(), - "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" - ); - assert_eq!( - tokens[1].address.to_string(), - "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063" - ); - assert_eq!( - tokens[2].address.to_string(), "0x0000000000000000000000000000000000000001" ); - assert_eq!(tokens[2].decimals, 18); - assert_eq!(tokens[2].name, "Token 3"); - assert_eq!(tokens[2].symbol, "T3"); + assert_eq!(tokens[0].decimals, 18); + assert_eq!(tokens[0].name, "Token 3"); + assert_eq!(tokens[0].symbol, "T3"); assert_eq!( - tokens[3].address.to_string(), + tokens[1].address.to_string(), "0x0000000000000000000000000000000000000002" ); - assert_eq!(tokens[3].decimals, 6); - assert_eq!(tokens[3].name, "Token 4"); - assert_eq!(tokens[3].symbol, "T4"); + assert_eq!(tokens[1].decimals, 6); + assert_eq!(tokens[1].name, "Token 4"); + assert_eq!(tokens[1].symbol, "T4"); } } From 88c9f66457520611659d0ece4f5a41e817965585 Mon Sep 17 00:00:00 2001 From: findolor Date: Wed, 18 Jun 2025 16:00:54 +0200 Subject: [PATCH 12/27] update tests --- packages/orderbook/test/js_api/gui.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/orderbook/test/js_api/gui.test.ts b/packages/orderbook/test/js_api/gui.test.ts index 7749c25c38..f93feca70c 100644 --- a/packages/orderbook/test/js_api/gui.test.ts +++ b/packages/orderbook/test/js_api/gui.test.ts @@ -1986,14 +1986,14 @@ ${dotrainWithoutVaultIds}`; const allTokens = extractWasmEncodedData(await gui.getAllTokens()); assert.equal(allTokens.length, 2); - assert.equal(allTokens[0].address, '0x8888888888888888888888888888888888888888'); - assert.equal(allTokens[0].name, 'Teken 2'); - assert.equal(allTokens[0].symbol, 'T2'); - assert.equal(allTokens[0].decimals, 18); - assert.equal(allTokens[1].address, '0x6666666666666666666666666666666666666666'); - assert.equal(allTokens[1].name, 'Token 1'); - assert.equal(allTokens[1].symbol, 'T1'); - assert.equal(allTokens[1].decimals, 6); + assert.equal(allTokens[0].address, '0x6666666666666666666666666666666666666666'); + assert.equal(allTokens[0].name, 'Token 1'); + assert.equal(allTokens[0].symbol, 'T1'); + assert.equal(allTokens[0].decimals, 6); + assert.equal(allTokens[1].address, '0x8888888888888888888888888888888888888888'); + assert.equal(allTokens[1].name, 'Teken 2'); + assert.equal(allTokens[1].symbol, 'T2'); + assert.equal(allTokens[1].decimals, 18); }); }); From 02268809668446e792fca787daf33386e90b3b99 Mon Sep 17 00:00:00 2001 From: findolor Date: Wed, 18 Jun 2025 16:03:24 +0200 Subject: [PATCH 13/27] rename file --- .../src/__tests__/SelectToken.test.ts | 2 +- ...wn.test.ts => TokenSelectionModal.test.ts} | 54 +++++++++---------- ...down.svelte => TokenSelectionModal.svelte} | 21 ++++---- 3 files changed, 39 insertions(+), 38 deletions(-) rename packages/ui-components/src/__tests__/{TokenDropdown.test.ts => TokenSelectionModal.test.ts} (88%) rename packages/ui-components/src/lib/components/deployment/{TokenDropdown.svelte => TokenSelectionModal.svelte} (87%) diff --git a/packages/ui-components/src/__tests__/SelectToken.test.ts b/packages/ui-components/src/__tests__/SelectToken.test.ts index b35cd9d1be..5931eaab01 100644 --- a/packages/ui-components/src/__tests__/SelectToken.test.ts +++ b/packages/ui-components/src/__tests__/SelectToken.test.ts @@ -240,7 +240,7 @@ describe('SelectToken', () => { expect(screen.getByTestId('dropdown-mode-button')).not.toHaveClass('border-blue-300'); }); - it('shows TokenDropdown component in dropdown mode', () => { + it('shows TokenSelectionModal component in dropdown mode', () => { render(SelectToken, mockProps); expect(screen.getByText('Select a token...')).toBeInTheDocument(); diff --git a/packages/ui-components/src/__tests__/TokenDropdown.test.ts b/packages/ui-components/src/__tests__/TokenSelectionModal.test.ts similarity index 88% rename from packages/ui-components/src/__tests__/TokenDropdown.test.ts rename to packages/ui-components/src/__tests__/TokenSelectionModal.test.ts index 84b14ccb96..1e3016c5c6 100644 --- a/packages/ui-components/src/__tests__/TokenDropdown.test.ts +++ b/packages/ui-components/src/__tests__/TokenSelectionModal.test.ts @@ -1,11 +1,11 @@ import { render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import TokenDropdown from '../lib/components/deployment/TokenDropdown.svelte'; +import TokenSelectionModal from '../lib/components/deployment/TokenSelectionModal.svelte'; import type { ComponentProps } from 'svelte'; import type { TokenInfo } from '@rainlanguage/orderbook'; -type TokenDropdownProps = ComponentProps; +type TokenSelectionModalProps = ComponentProps; const mockTokens: TokenInfo[] = [ { @@ -28,11 +28,11 @@ const mockTokens: TokenInfo[] = [ } ]; -describe('TokenDropdown', () => { +describe('TokenSelectionModal', () => { let mockOnSelect: ReturnType; let mockOnSearch: ReturnType; - const defaultProps: TokenDropdownProps = { + const defaultProps: TokenSelectionModalProps = { tokens: mockTokens, selectedToken: null, onSelect: vi.fn(), @@ -46,8 +46,8 @@ describe('TokenDropdown', () => { vi.clearAllMocks(); }); - it('renders dropdown button with default text when no token is selected', () => { - render(TokenDropdown, { + it('renders modal button with default text when no token is selected', () => { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -56,9 +56,9 @@ describe('TokenDropdown', () => { expect(screen.getByText('Select a token...')).toBeInTheDocument(); }); - it('renders dropdown button with selected token info when token is selected', () => { + it('renders modal button with selected token info when token is selected', () => { const selectedToken = mockTokens[0]; - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, selectedToken, onSelect: mockOnSelect, @@ -68,9 +68,9 @@ describe('TokenDropdown', () => { expect(screen.getByText('Test Token 1 (TEST1)')).toBeInTheDocument(); }); - it('opens dropdown when button is clicked', async () => { + it('opens modal when button is clicked', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -82,9 +82,9 @@ describe('TokenDropdown', () => { expect(screen.getByPlaceholderText('Search tokens...')).toBeInTheDocument(); }); - it('displays all tokens in the dropdown list', async () => { + it('displays all tokens in the modal list', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -103,7 +103,7 @@ describe('TokenDropdown', () => { it('displays formatted addresses in token list', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -120,7 +120,7 @@ describe('TokenDropdown', () => { it('highlights selected token in the list', async () => { const user = userEvent.setup(); const selectedToken = mockTokens[1]; - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, selectedToken, onSelect: mockOnSelect, @@ -138,7 +138,7 @@ describe('TokenDropdown', () => { it('calls onSelect when token is clicked', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -157,9 +157,9 @@ describe('TokenDropdown', () => { expect(mockOnSelect).toHaveBeenCalledWith(mockTokens[0]); }); - it('closes dropdown after token selection', async () => { + it('closes modal after token selection', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -182,7 +182,7 @@ describe('TokenDropdown', () => { it('filters tokens based on search input', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'test', onSelect: mockOnSelect, @@ -199,7 +199,7 @@ describe('TokenDropdown', () => { it('calls onSearch when search input changes', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -216,7 +216,7 @@ describe('TokenDropdown', () => { it('filters tokens by symbol', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'ANOTHER', onSelect: mockOnSelect, @@ -233,7 +233,7 @@ describe('TokenDropdown', () => { it('filters tokens by address', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: '0x1234', onSelect: mockOnSelect, @@ -250,7 +250,7 @@ describe('TokenDropdown', () => { it('shows "no results" message when no tokens match search', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'nonexistent', onSelect: mockOnSelect, @@ -266,7 +266,7 @@ describe('TokenDropdown', () => { it('clears search when "Clear search" button is clicked', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'nonexistent', onSelect: mockOnSelect, @@ -284,7 +284,7 @@ describe('TokenDropdown', () => { it('handles token selection via keyboard (Enter key)', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, onSelect: mockOnSelect, onSearch: mockOnSearch @@ -304,7 +304,7 @@ describe('TokenDropdown', () => { it('displays empty state when no tokens are provided', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, tokens: [], onSelect: mockOnSelect, @@ -319,7 +319,7 @@ describe('TokenDropdown', () => { it('maintains search value in input field', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'initial search', onSelect: mockOnSelect, @@ -335,7 +335,7 @@ describe('TokenDropdown', () => { it('search is case insensitive', async () => { const user = userEvent.setup(); - render(TokenDropdown, { + render(TokenSelectionModal, { ...defaultProps, searchValue: 'TEST', onSelect: mockOnSelect, diff --git a/packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte b/packages/ui-components/src/lib/components/deployment/TokenSelectionModal.svelte similarity index 87% rename from packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte rename to packages/ui-components/src/lib/components/deployment/TokenSelectionModal.svelte index a4a9074e18..aa8982f61a 100644 --- a/packages/ui-components/src/lib/components/deployment/TokenDropdown.svelte +++ b/packages/ui-components/src/lib/components/deployment/TokenSelectionModal.svelte @@ -1,5 +1,5 @@