diff --git a/crates/common/src/raindex_client/add_orders.rs b/crates/common/src/raindex_client/add_orders.rs index bbf727f0e5..da8bc2ecc0 100644 --- a/crates/common/src/raindex_client/add_orders.rs +++ b/crates/common/src/raindex_client/add_orders.rs @@ -87,17 +87,10 @@ mod tests { mod non_wasm { use super::*; use crate::raindex_client::tests::{get_test_yaml, CHAIN_ID_1_ORDERBOOK_ADDRESS}; - use alloy::{ - hex::encode_prefixed, - primitives::{Address, B256, U256}, - sol_types::SolValue, - }; - use alloy_ethers_typecast::rpc::Response; + use alloy::primitives::{Address, U256}; use httpmock::MockServer; - use rain_orderbook_app_settings::spec_version::SpecVersion; - use rain_orderbook_bindings::IOrderBookV4::IO; use serde_json::json; - use std::{collections::HashMap, str::FromStr}; + use std::str::FromStr; #[tokio::test] async fn test_get_transaction_add_orders() { diff --git a/crates/js_api/src/gui/mod.rs b/crates/js_api/src/gui/mod.rs index 2f62f74716..e40c8ebe7f 100644 --- a/crates/js_api/src/gui/mod.rs +++ b/crates/js_api/src/gui/mod.rs @@ -18,8 +18,12 @@ use rain_orderbook_common::{ erc20::ERC20, }; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use std::io::prelude::*; +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, +}; +use strict_yaml_rust::StrictYaml; use thiserror::Error; use wasm_bindgen_utils::{impl_wasm_traits, prelude::*, wasm_export}; @@ -98,10 +102,8 @@ impl DotrainOrderGui { )] dotrain: String, ) -> Result, GuiError> { - let dotrain_order = DotrainOrder::create(dotrain.clone(), None).await?; - Ok(GuiCfg::parse_deployment_keys( - dotrain_order.dotrain_yaml().documents.clone(), - )?) + let documents = DotrainOrderGui::get_yaml_documents(&dotrain)?; + Ok(GuiCfg::parse_deployment_keys(documents)?) } /// Creates a new GUI instance for managing a specific deployment configuration. @@ -402,10 +404,8 @@ impl DotrainOrderGui { pub async fn get_strategy_details( #[wasm_export(param_description = "Complete dotrain YAML content")] dotrain: String, ) -> Result { - let dotrain_order = DotrainOrder::create(dotrain.clone(), None).await?; - let details = - GuiCfg::parse_strategy_details(dotrain_order.dotrain_yaml().documents.clone())?; - Ok(details) + let documents = DotrainOrderGui::get_yaml_documents(&dotrain)?; + Ok(GuiCfg::parse_strategy_details(documents)?) } /// Gets metadata for all deployments defined in the configuration. @@ -443,10 +443,8 @@ impl DotrainOrderGui { pub async fn get_deployment_details( #[wasm_export(param_description = "Complete dotrain YAML content")] dotrain: String, ) -> Result, GuiError> { - let dotrain_order = DotrainOrder::create(dotrain.clone(), None).await?; - Ok(GuiCfg::parse_deployment_details( - dotrain_order.dotrain_yaml().documents.clone(), - )?) + let documents = DotrainOrderGui::get_yaml_documents(&dotrain)?; + Ok(GuiCfg::parse_deployment_details(documents)?) } /// Gets metadata for a specific deployment by key. @@ -592,6 +590,15 @@ impl DotrainOrderGui { Ok(rainlang) } } +impl DotrainOrderGui { + pub fn get_yaml_documents(dotrain: &str) -> Result>>, GuiError> { + let frontmatter = RainDocument::get_front_matter(&dotrain) + .unwrap_or("") + .to_string(); + let dotrain_yaml = DotrainYaml::new(vec![frontmatter.clone()], false)?; + Ok(dotrain_yaml.documents) + } +} #[derive(Error, Debug)] pub enum GuiError { diff --git a/crates/js_api/src/gui/select_tokens.rs b/crates/js_api/src/gui/select_tokens.rs index 38edd03485..5d7ff812f5 100644 --- a/crates/js_api/src/gui/select_tokens.rs +++ b/crates/js_api/src/gui/select_tokens.rs @@ -309,9 +309,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) @@ -553,26 +557,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"); } } diff --git a/crates/settings/src/remote/tokens.rs b/crates/settings/src/remote/tokens.rs index 1586be88f5..60d456f712 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, } @@ -43,28 +44,31 @@ impl Token { self, networks: &HashMap, document: Arc>, - ) -> Result { - let network = networks + ) -> Result, RemoteTokensError> { + match networks .values() .find(|network| network.chain_id == self.chain_id) - .ok_or(RemoteTokensError::NetworkNotFound(format!( - "network with chain_id {}", - self.chain_id - ))) - .cloned()?; - - let token_cfg = TokenCfg { - document: document.clone(), - key: self.name.to_lowercase().replace(' ', "-").clone(), - network: Arc::new(network), - address: Address::from_str(&self.address) - .map_err(|e| RemoteTokensError::ParseTokenAddressError(e.to_string()))?, - decimals: Some(self.decimals as u8), - label: Some(self.name.clone()), - symbol: Some(self.symbol), - }; - - Ok(token_cfg) + { + Some(network) => { + let token_cfg = TokenCfg { + document: document.clone(), + key: format!( + "{}-{}-{}", + network.key, + self.name.replace(' ', "-").clone(), + self.address.to_lowercase() + ), + network: Arc::new(network.clone()), + address: Address::from_str(&self.address) + .map_err(|e| RemoteTokensError::ParseTokenAddressError(e.to_string()))?, + decimals: Some(self.decimals as u8), + label: Some(self.name.clone()), + symbol: Some(self.symbol), + }; + Ok(Some(token_cfg)) + } + None => Ok(None), + } } } diff --git a/crates/settings/src/remote_tokens.rs b/crates/settings/src/remote_tokens.rs index 46e6c29124..0971c252a6 100644 --- a/crates/settings/src/remote_tokens.rs +++ b/crates/settings/src/remote_tokens.rs @@ -1,4 +1,4 @@ -use crate::remote::tokens::{RemoteTokensError, Tokens}; +use crate::remote::tokens::{RemoteTokensError, Token, Tokens}; use crate::yaml::context::Context; use crate::yaml::{ default_document, optional_vec, require_string, FieldErrorKind, YamlError, YamlParseableValue, @@ -41,17 +41,24 @@ impl RemoteTokensCfg { .json::() .await?; + let mut unique_tokens: HashMap<(u32, String), Token> = HashMap::new(); for token in &tokens_res.tokens { - let token_cfg = token - .clone() - .try_into_token_cfg(networks, remote_tokens.document.clone())?; + let token_id = (token.chain_id, token.address.to_lowercase()); + unique_tokens.insert(token_id, token.clone()); + } - if tokens.contains_key(&token_cfg.key) { - return Err(ParseRemoteTokensError::ConflictingTokens( - token_cfg.key.clone(), - )); + for token in unique_tokens.values() { + if let Some(token_cfg) = token + .clone() + .try_into_token_cfg(networks, remote_tokens.document.clone())? + { + if tokens.contains_key(&token_cfg.key) { + return Err(ParseRemoteTokensError::ConflictingTokens( + token_cfg.key.clone(), + )); + } + tokens.insert(token_cfg.key.clone(), token_cfg); } - tokens.insert(token_cfg.key.clone(), token_cfg); } } @@ -235,7 +242,7 @@ using-tokens-from: "decimals": 18 } ], - "logoUri": "http://localhost.com" + "logoURI": "http://localhost.com" } "#; server @@ -279,8 +286,9 @@ using-tokens-from: assert_eq!(tokens.len(), 2_usize); - let token = tokens.get("token1").unwrap(); - assert_eq!(token.key, "token1"); + let token1_key = "remote-network-Token1-0x0000000000000000000000000000000000000001"; + let token = tokens.get(token1_key).unwrap(); + assert_eq!(token.key, token1_key); assert_eq!( token.address, Address::from_str("0x0000000000000000000000000000000000000001").unwrap() @@ -288,8 +296,9 @@ using-tokens-from: assert_eq!(token.network.key, "remote-network"); assert_eq!(token.network.chain_id, 123); - let token = tokens.get("token2").unwrap(); - assert_eq!(token.key, "token2"); + let token2_key = "remote2-network-Token2-0x0000000000000000000000000000000000000002"; + let token = tokens.get(token2_key).unwrap(); + assert_eq!(token.key, token2_key); assert_eq!( token.address, Address::from_str("0x0000000000000000000000000000000000000002").unwrap() @@ -343,7 +352,7 @@ using-tokens-from: "decimals": 18 } ], - "logoUri": "http://localhost.com" + "logoURI": "http://localhost.com" } "#; @@ -373,7 +382,7 @@ using-tokens-from: "decimals": 18 } ], - "logoUri": "http://localhost.com" + "logoURI": "http://localhost.com" } "#; @@ -452,8 +461,9 @@ using-tokens-from: assert_eq!(tokens.len(), 4_usize); - let token = tokens.get("token3").unwrap(); - assert_eq!(token.key, "token3"); + let token3_key = "remote3-network-Token3-0x0000000000000000000000000000000000000003"; + let token = tokens.get(token3_key).unwrap(); + assert_eq!(token.key, token3_key); assert_eq!( token.address, Address::from_str("0x0000000000000000000000000000000000000003").unwrap() @@ -461,8 +471,9 @@ using-tokens-from: assert_eq!(token.network.key, "remote3-network"); assert_eq!(token.network.chain_id, 345); - let token = tokens.get("token4").unwrap(); - assert_eq!(token.key, "token4"); + let token4_key = "remote4-network-Token4-0x0000000000000000000000000000000000000004"; + let token = tokens.get(token4_key).unwrap(); + assert_eq!(token.key, token4_key); assert_eq!( token.address, Address::from_str("0x0000000000000000000000000000000000000004").unwrap() diff --git a/packages/orderbook/test/js_api/gui.test.ts b/packages/orderbook/test/js_api/gui.test.ts index 2dfb56ece1..c203b6ccf5 100644 --- a/packages/orderbook/test/js_api/gui.test.ts +++ b/packages/orderbook/test/js_api/gui.test.ts @@ -1998,14 +1998,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); }); }); @@ -2043,7 +2043,7 @@ ${dotrainWithoutVaultIds}`; patch: 0 }, tokens: [], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'test-deployment'); @@ -2143,7 +2143,7 @@ ${dotrainWithoutVaultIds}`; patch: 0 }, tokens: [], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'test-deployment'); @@ -2199,131 +2199,12 @@ ${dotrainWithoutVaultIds}`; decimals: 18 } ], - logoUri: 'http://localhost.com' + logoURI: 'http://localhost.com' }); const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'other-deployment'); const gui = extractWasmEncodedData(result); assert.ok(gui.getCurrentDeployment()); }); - - it('should fail for same remote token key in response', async () => { - mockServer - .forGet('/remote-networks') - .once() - .thenJson(200, [ - { - name: 'Remote', - chain: 'remote-network', - chainId: 123, - rpc: ['http://localhost:8085/rpc-url'], - networkId: 123, - nativeCurrency: { - name: 'Remote', - symbol: 'RN', - decimals: 18 - }, - infoURL: 'http://localhost:8085/info-url', - shortName: 'remote-network' - } - ]); - mockServer - .forGet('/remote-tokens') - .once() - .thenJson(200, { - name: 'Remote', - timestamp: '2021-01-01T00:00:00.000Z', - keywords: [], - version: { - major: 1, - minor: 0, - patch: 0 - }, - tokens: [ - { - chainId: 123, - address: '0x0000000000000000000000000000000000000000', - name: 'Remote', - symbol: 'RN', - decimals: 18 - }, - { - chainId: 123, - address: '0x0000000000000000000000000000000000000000', - name: 'Remote', - symbol: 'RN', - decimals: 18 - } - ], - logoUri: 'http://localhost.com' - }); - - const result = await DotrainOrderGui.newWithDeployment(dotrainForRemotes, 'other-deployment'); - if (!result.error) expect.fail('Expected error'); - expect(result.error.msg).toBe( - "Conflicting remote token in response, a token with key 'remote' already exists" - ); - expect(result.error.readableMsg).toBe( - "Order configuration error in YAML: Conflicting remote token in response, a token with key 'remote' already exists" - ); - }); - - it('should fail for duplicate token', async () => { - mockServer - .forGet('/remote-networks') - .once() - .thenJson(200, [ - { - name: 'Remote', - chain: 'remote-network', - chainId: 123, - rpc: ['http://localhost:8085/rpc-url'], - networkId: 123, - nativeCurrency: { - name: 'Remote', - symbol: 'RN', - decimals: 18 - }, - infoURL: 'http://localhost:8085/info-url', - shortName: 'remote-network' - } - ]); - mockServer - .forGet('/remote-tokens') - .once() - .thenJson(200, { - name: 'Remote', - timestamp: '2021-01-01T00:00:00.000Z', - keywords: [], - version: { - major: 1, - minor: 0, - patch: 0 - }, - tokens: [ - { - chainId: 123, - address: '0x0000000000000000000000000000000000000000', - name: 'Token1', - symbol: 'RN', - decimals: 18 - } - ], - logoUri: 'http://localhost.com' - }); - - const guiResult = await DotrainOrderGui.newWithDeployment( - dotrainForRemotes, - 'other-deployment' - ); - const gui = extractWasmEncodedData(guiResult); - - const result = gui.getCurrentDeployment(); - if (!result.error) expect.fail('Expected error'); - expect(result.error.msg).toBe('Remote token key shadowing: token1'); - expect(result.error.readableMsg).toBe( - 'YAML configuration error: Remote token key shadowing: token1' - ); - }); }); }); diff --git a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts index 7e0004d7dc..5cf22f908d 100644 --- a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts @@ -112,15 +112,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 }), setSelectToken: vi.fn(), + unsetSelectToken: vi.fn(), getDeploymentTransactionArgs: vi.fn() } as unknown as DotrainOrderGui; @@ -307,7 +331,9 @@ describe('DeploymentSteps', () => { props: defaultProps }); - expect(mockGui.areAllTokensSelected).toHaveBeenCalled(); + await waitFor(() => { + expect(mockGui.areAllTokensSelected).toHaveBeenCalled(); + }); await waitFor(() => { expect(screen.getByText('Select Tokens')).toBeInTheDocument(); @@ -315,7 +341,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', @@ -326,7 +352,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', @@ -341,7 +367,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', @@ -350,7 +379,7 @@ describe('DeploymentSteps', () => { symbol: 'TKN3' } }); - await user.type(selectTokenInput, '0x3'); + await user.type(lastCustomInput, '0x3'); (mockGui.getAllTokenInfos as Mock).mockResolvedValue({ value: [ @@ -402,4 +431,169 @@ describe('DeploymentSteps', () => { expect(raindexClient).toBe(mockRaindexClient); }); }); + + // 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/__tests__/SelectToken.test.ts b/packages/ui-components/src/__tests__/SelectToken.test.ts index b4276981ec..f157d5e4ee 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 = { setSelectToken: vi.fn(), isSelectTokenSet: vi.fn(), + unsetSelectToken: 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 setSelectToken and updates token info when 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.setSelectToken).toHaveBeenCalledWith('input', '0x456'); + expect(mockGuiWithNoToken.setSelectToken).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,14 +128,18 @@ 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.setSelectToken).not.toHaveBeenCalled(); @@ -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.setSelectToken).toHaveBeenCalled(); + expect(mockGuiWithTokenSet.setSelectToken).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,199 @@ 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('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 TokenSelectionModal 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(); + const mockGuiNoToken = { + ...mockGui, + getTokenInfo: vi.fn().mockResolvedValue({ value: null }) + } as unknown as DotrainOrderGui; + + (useGui as Mock).mockReturnValue(mockGuiNoToken); + + 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(mockGuiNoToken.unsetSelectToken).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.unsetSelectToken).toHaveBeenCalledWith('input'); + }); + + 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: [ + { + 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(mockGuiNoToken.setSelectToken).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/__tests__/TokenSelectionModal.test.ts b/packages/ui-components/src/__tests__/TokenSelectionModal.test.ts new file mode 100644 index 0000000000..1e3016c5c6 --- /dev/null +++ b/packages/ui-components/src/__tests__/TokenSelectionModal.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 TokenSelectionModal from '../lib/components/deployment/TokenSelectionModal.svelte'; +import type { ComponentProps } from 'svelte'; +import type { TokenInfo } from '@rainlanguage/orderbook'; + +type TokenSelectionModalProps = 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('TokenSelectionModal', () => { + let mockOnSelect: ReturnType; + let mockOnSearch: ReturnType; + + const defaultProps: TokenSelectionModalProps = { + tokens: mockTokens, + selectedToken: null, + onSelect: vi.fn(), + searchValue: '', + onSearch: vi.fn() + }; + + beforeEach(() => { + mockOnSelect = vi.fn(); + mockOnSearch = vi.fn(); + vi.clearAllMocks(); + }); + + it('renders modal button with default text when no token is selected', () => { + render(TokenSelectionModal, { + ...defaultProps, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + expect(screen.getByText('Select a token...')).toBeInTheDocument(); + }); + + it('renders modal button with selected token info when token is selected', () => { + const selectedToken = mockTokens[0]; + render(TokenSelectionModal, { + ...defaultProps, + selectedToken, + onSelect: mockOnSelect, + onSearch: mockOnSearch + }); + + expect(screen.getByText('Test Token 1 (TEST1)')).toBeInTheDocument(); + }); + + it('opens modal when button is clicked', async () => { + const user = userEvent.setup(); + render(TokenSelectionModal, { + ...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 modal list', async () => { + const user = userEvent.setup(); + render(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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 modal after token selection', async () => { + const user = userEvent.setup(); + render(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(TokenSelectionModal, { + ...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(); + }); +}); diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte index 49217405cb..adac48544a 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 allTokenInfos: TokenInfo[] = []; let selectTokens: GuiSelectTokensCfg[] | undefined = undefined; let checkingDeployment: boolean = false; + let availableTokens: TokenInfo[] = []; + let loadingTokens: boolean = false; const gui = useGui(); const registry = useRegistry(); @@ -69,12 +71,31 @@ } selectTokens = selectTokensResult.value; await areAllTokensSelected(); + await loadAvailableTokens(); }); $: if (selectTokens?.length === 0 || allTokensSelected) { updateFields(); } + 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 getAllGuiConfig() { try { let result = gui.getAllGuiConfig(); @@ -197,7 +218,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/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index d46b27dc14..01daf19991 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -5,13 +5,21 @@ import { Spinner } from 'flowbite-svelte'; import { onMount } from 'svelte'; import { useGui } from '$lib/hooks/useGui'; + import ButtonSelectOption from './ButtonSelectOption.svelte'; + import TokenSelectionModal from './TokenSelectionModal.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(); @@ -22,14 +30,92 @@ throw new Error(result.error.msg); } tokenInfo = result.value; - if (tokenInfo?.address) { - inputValue = tokenInfo.address; + if (result.value?.address) { + inputValue = result.value.address; } } catch { // do nothing } }); + $: if (tokenInfo?.address && availableTokens.length > 0) { + const foundToken = availableTokens.find( + (t) => t.address.toLowerCase() === tokenInfo?.address.toLowerCase() + ); + selectedToken = foundToken || null; + + if (inputValue === null) { + inputValue = tokenInfo.address; + } + if (!foundToken && selectionMode === 'dropdown') { + selectionMode = 'custom'; + } + } else 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.setSelectToken(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.unsetSelectToken(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.setSelectToken(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 && !loading} + + {/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/TokenSelectionModal.svelte b/packages/ui-components/src/lib/components/deployment/TokenSelectionModal.svelte new file mode 100644 index 0000000000..aa8982f61a --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/TokenSelectionModal.svelte @@ -0,0 +1,116 @@ + + +
+
+ + + +
+

Select a token

+
+
+
+ +
+ +
+ +
+ {#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} +
+
+
+
diff --git a/packages/ui-components/src/lib/errors/DeploymentStepsError.ts b/packages/ui-components/src/lib/errors/DeploymentStepsError.ts index 8b2e7f482a..1f6aa04f07 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', diff --git a/packages/ui-components/test-setup.ts b/packages/ui-components/test-setup.ts index 76e60f9aed..28dd134290 100644 --- a/packages/ui-components/test-setup.ts +++ b/packages/ui-components/test-setup.ts @@ -74,6 +74,7 @@ vi.mock('@rainlanguage/orderbook', () => { DotrainOrderGui.prototype.getAllFieldDefinitions = vi.fn(); DotrainOrderGui.prototype.isSelectTokenSet = vi.fn(); DotrainOrderGui.prototype.setSelectToken = vi.fn(); + DotrainOrderGui.prototype.unsetSelectToken = vi.fn(); DotrainOrderGui.prototype.hasAnyDeposit = vi.fn(); DotrainOrderGui.prototype.hasAnyVaultId = vi.fn(); DotrainOrderGui.prototype.areAllTokensSelected = vi.fn(); diff --git a/packages/webapp/src/lib/constants.ts b/packages/webapp/src/lib/constants.ts index 0858b04203..a5a6dc595c 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/3ef26d1cc2a127cd7c096299d6f852af966285a5/registry'; + 'https://raw.githubusercontent.com/rainlanguage/rain.strategies/baf9c13ad826189e4e50dcaf63e86089e7c4455b/registry'; 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 598f1ee04f..8bc8d54670 100644 --- a/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts +++ b/packages/webapp/src/routes/deploy/[strategyName]/[deploymentKey]/fullDeployment.test.ts @@ -125,7 +125,7 @@ describe('Full Deployment Tests', () => { }); afterEach(async () => { - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); }); it( @@ -146,29 +146,38 @@ describe('Full Deployment Tests', () => { const screen = render(Page); // Wait for the gui provider to be in the document - await waitFor(() => { - expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); - }); - - // Get all the current input elements for select tokens - const selectTokenInputs = screen.getAllByRole('textbox') as HTMLInputElement[]; - - const buyTokenInput = selectTokenInputs[0]; - const sellTokenInput = selectTokenInputs[1]; + await waitFor( + () => { + expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); - // Select the buy token - await userEvent.clear(buyTokenInput); - await userEvent.type(buyTokenInput, '0x1D80c49BbBCd1C0911346656B529DF9E5c2F783d'); - await waitFor(() => { - expect(screen.getByTestId('select-token-success-token1')).toBeInTheDocument(); - }); + await waitFor( + () => { + expect(screen.getAllByRole('button', { name: /chevron down solid/i }).length).toBe(2); + }, + { timeout: 300000 } + ); + const tokenSelectionButtons = screen.getAllByRole('button', { name: /chevron down solid/i }); + + await userEvent.click(tokenSelectionButtons[0]); + await userEvent.click(screen.getByText('Staked FLR')); + await waitFor( + () => { + expect(screen.getByTestId('select-token-success-token1')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); - // Select the sell token - await userEvent.clear(sellTokenInput); - await userEvent.type(sellTokenInput, '0x12e605bc104e93B45e1aD99F9e555f659051c2BB'); - await waitFor(() => { - expect(screen.getByTestId('select-token-success-token2')).toBeInTheDocument(); - }); + await userEvent.click(tokenSelectionButtons[1]); + await userEvent.click(screen.getByText('Wrapped FLR')); + await waitFor( + () => { + expect(screen.getByTestId('select-token-success-token2')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); // Get the input component and write "10" into it const customValueInput = screen.getAllByPlaceholderText('Enter custom value')[0]; @@ -192,10 +201,13 @@ describe('Full Deployment Tests', () => { const deployButton = screen.getByText('Deploy Strategy'); await userEvent.click(deployButton); - await waitFor(async () => { - const disclaimerButton = screen.getByText('Deploy'); - await userEvent.click(disclaimerButton); - }); + await waitFor( + async () => { + const disclaimerButton = screen.getByText('Deploy'); + await userEvent.click(disclaimerButton); + }, + { timeout: 300000 } + ); const getDeploymentArgs = async () => { const gui = (await DotrainOrderGui.newWithDeployment(fixedLimitStrategy, 'flare')) @@ -210,7 +222,7 @@ describe('Full Deployment Tests', () => { ); return args.value; }; - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); const args = await getDeploymentArgs().catch((error) => { // eslint-disable-next-line no-console console.log('Fixed limit strategy error', error); @@ -240,7 +252,7 @@ describe('Full Deployment Tests', () => { expect(callArgs.args.toAddress).toEqual(args?.orderbookAddress); expect(callArgs.args.chainId).toEqual(args?.chainId); }, - { timeout: 30000 } + { timeout: 300000 } ); it( @@ -262,29 +274,39 @@ describe('Full Deployment Tests', () => { const screen = render(Page); // Wait for the gui provider to be in the document - await waitFor(() => { - 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]; + await waitFor( + () => { + expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); - // Select the sell token - await userEvent.clear(sellTokenInput); - await userEvent.type(sellTokenInput, '0x12e605bc104e93B45e1aD99F9e555f659051c2BB'); - await waitFor(() => { - expect(screen.getByTestId('select-token-success-output')).toBeInTheDocument(); - }); + // Check that the token dropdowns are present + await waitFor( + () => { + expect(screen.getAllByRole('button', { name: /chevron down solid/i }).length).toBe(2); + }, + { timeout: 300000 } + ); + const tokenSelectionButtons = screen.getAllByRole('button', { name: /chevron down solid/i }); + + await userEvent.click(tokenSelectionButtons[0]); + await userEvent.click(screen.getByText('Staked FLR')); + await waitFor( + () => { + expect(screen.getByTestId('select-token-success-output')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); - // Select the buy token - await userEvent.clear(buyTokenInput); - await userEvent.type(buyTokenInput, '0x1D80c49BbBCd1C0911346656B529DF9E5c2F783d'); - await waitFor(() => { - expect(screen.getByTestId('select-token-success-input')).toBeInTheDocument(); - }); + await userEvent.click(tokenSelectionButtons[1]); + await userEvent.click(screen.getByText('Wrapped FLR')); + await waitFor( + () => { + expect(screen.getByTestId('select-token-success-input')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); const timePerAmountEpochInput = screen.getByTestId( 'binding-time-per-amount-epoch-input' @@ -335,10 +357,13 @@ describe('Full Deployment Tests', () => { const deployButton = screen.getByText('Deploy Strategy'); await userEvent.click(deployButton); - await waitFor(async () => { - const disclaimerButton = screen.getByText('Deploy'); - await userEvent.click(disclaimerButton); - }); + await waitFor( + async () => { + const disclaimerButton = screen.getByText('Deploy'); + await userEvent.click(disclaimerButton); + }, + { timeout: 300000 } + ); const getDeploymentArgs = async () => { const gui = (await DotrainOrderGui.newWithDeployment(auctionStrategy, 'flare')) @@ -358,7 +383,7 @@ describe('Full Deployment Tests', () => { ); return args.value; }; - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); const args = await getDeploymentArgs().catch((error) => { // eslint-disable-next-line no-console console.log('Auction strategy error', error); @@ -388,7 +413,7 @@ describe('Full Deployment Tests', () => { expect(callArgs.args.toAddress).toEqual(args?.orderbookAddress); expect(callArgs.args.chainId).toEqual(args?.chainId); }, - { timeout: 30000 } + { timeout: 300000 } ); it( @@ -410,29 +435,35 @@ describe('Full Deployment Tests', () => { const screen = render(Page); // Wait for the gui provider to be in the document - await waitFor(() => { - expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); - }); - - // Get all the current input elements for select tokens - const selectTokenInputs = screen.getAllByRole('textbox') as HTMLInputElement[]; + await waitFor( + () => { + expect(screen.getByTestId('gui-provider')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); - const firstTokenInput = selectTokenInputs[0]; - const secondTokenInput = selectTokenInputs[1]; + await waitFor( + () => { + expect(screen.getAllByRole('button', { name: /chevron down solid/i }).length).toBe(2); + }, + { timeout: 300000 } + ); + const tokenSelectionButtons = screen.getAllByRole('button', { name: /chevron down solid/i }); - // Select the first token - await userEvent.clear(firstTokenInput); - await userEvent.type(firstTokenInput, '0x1D80c49BbBCd1C0911346656B529DF9E5c2F783d'); + await userEvent.click(tokenSelectionButtons[0]); + await userEvent.click(screen.getByText('Staked FLR')); await waitFor(() => { expect(screen.getByTestId('select-token-success-token1')).toBeInTheDocument(); }); - // Select the second token - await userEvent.clear(secondTokenInput); - await userEvent.type(secondTokenInput, '0x12e605bc104e93B45e1aD99F9e555f659051c2BB'); - await waitFor(() => { - expect(screen.getByTestId('select-token-success-token2')).toBeInTheDocument(); - }); + await userEvent.click(tokenSelectionButtons[1]); + await userEvent.click(screen.getByText('Wrapped FLR')); + await waitFor( + () => { + expect(screen.getByTestId('select-token-success-token2')).toBeInTheDocument(); + }, + { timeout: 300000 } + ); const amountIsFastExitButton = screen.getByTestId( 'binding-amount-is-fast-exit-preset-Yes' @@ -473,10 +504,13 @@ describe('Full Deployment Tests', () => { const deployButton = screen.getByText('Deploy Strategy'); await userEvent.click(deployButton); - await waitFor(async () => { - const disclaimerButton = screen.getByText('Deploy'); - await userEvent.click(disclaimerButton); - }); + await waitFor( + async () => { + const disclaimerButton = screen.getByText('Deploy'); + await userEvent.click(disclaimerButton); + }, + { timeout: 300000 } + ); const getDeploymentArgs = async () => { const gui = (await DotrainOrderGui.newWithDeployment(dynamicSpreadStrategy, 'flare')) @@ -495,7 +529,7 @@ describe('Full Deployment Tests', () => { ); return args.value; }; - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); const args = await getDeploymentArgs().catch((error) => { // eslint-disable-next-line no-console console.log('Dynamic spread strategy error', error); @@ -525,6 +559,6 @@ describe('Full Deployment Tests', () => { expect(callArgs.args.toAddress).toEqual(args?.orderbookAddress); expect(callArgs.args.chainId).toEqual(args?.chainId); }, - { timeout: 30000 } + { timeout: 300000 } ); });