From 2c1b5d5d7f3f7e3282f0b798e633cf27c50e0218 Mon Sep 17 00:00:00 2001 From: Pedro Rezende Date: Thu, 19 Sep 2024 10:38:03 -0300 Subject: [PATCH] Namadillo: first sketch on Transfer module (#1119) * feat(namadillo): creating basic file structure * feat(namadillo): creating ChainSelectBox component and relative structure * feat(namadillo): button connect wallet * feat(namadillo): adding selected asset button * feat(namadillo): adding AvailableAmountFooter component * feat(namadillo): creating simple TransferDestination component * feat(namadillo): writing tests for TransferDestination * feat(namadillo): improving and writing tests for TransferSource component * feat(namadillo): sketching modals and organizing code * feat(namadillo): verifying if provider is connected and improving modals * feat(namadillo): renaming components * feat: addressing reviewing comments --- apps/namadillo/src/App/Common/SelectModal.tsx | 40 +++++++ apps/namadillo/src/App/Common/TabSelector.tsx | 42 +++++++ .../App/Transfer/AvailableAmountFooter.tsx | 54 +++++++++ apps/namadillo/src/App/Transfer/ChainCard.tsx | 0 .../App/Transfer/ConnectProviderButton.tsx | 21 ++++ .../src/App/Transfer/CustomAddressForm.tsx | 35 ++++++ .../src/App/Transfer/EmptyResourceIcon.tsx | 20 ++++ .../src/App/Transfer/IBCFromNamadaModule.tsx | 9 ++ .../src/App/Transfer/IBCTransfers.tsx | 12 ++ .../src/App/Transfer/SelectAssetsModal.tsx | 15 +++ .../src/App/Transfer/SelectChainModal.tsx | 34 ++++++ .../src/App/Transfer/SelectProviderModal.tsx | 43 ++++++++ .../src/App/Transfer/SelectedAsset.tsx | 51 +++++++++ .../src/App/Transfer/SelectedChain.tsx | 53 +++++++++ apps/namadillo/src/App/Transfer/Transfer.tsx | 5 + .../src/App/Transfer/TransferDestination.tsx | 103 ++++++++++++++++++ .../src/App/Transfer/TransferModule.tsx | 85 +++++++++++++++ .../src/App/Transfer/TransferSource.tsx | 64 +++++++++++ .../src/App/Transfer/__mocks__/chains.ts | 13 +++ .../src/App/Transfer/__mocks__/providers.ts | 12 ++ .../__tests__/AvailableAmountFooter.test.tsx | 52 +++++++++ .../Transfer/__tests__/SelectedAsset.test.tsx | 71 ++++++++++++ .../Transfer/__tests__/SelectedChain.test.tsx | 67 ++++++++++++ .../__tests__/TransferDestination.test.tsx | 100 +++++++++++++++++ .../__tests__/TransferSource.test.tsx | 87 +++++++++++++++ .../App/Transfer/assets/namada-shielded.svg | 1 + .../Transfer/assets/namada-transparent.svg | 1 + apps/namadillo/src/App/Transfer/routes.ts | 3 + apps/namadillo/src/types.d.ts | 21 ++++ packages/components/src/AmountInput.tsx | 4 +- packages/components/src/Input.tsx | 8 +- packages/components/src/Modal.tsx | 1 + 32 files changed, 1124 insertions(+), 3 deletions(-) create mode 100644 apps/namadillo/src/App/Common/SelectModal.tsx create mode 100644 apps/namadillo/src/App/Common/TabSelector.tsx create mode 100644 apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx create mode 100644 apps/namadillo/src/App/Transfer/ChainCard.tsx create mode 100644 apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx create mode 100644 apps/namadillo/src/App/Transfer/CustomAddressForm.tsx create mode 100644 apps/namadillo/src/App/Transfer/EmptyResourceIcon.tsx create mode 100644 apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx create mode 100644 apps/namadillo/src/App/Transfer/IBCTransfers.tsx create mode 100644 apps/namadillo/src/App/Transfer/SelectAssetsModal.tsx create mode 100644 apps/namadillo/src/App/Transfer/SelectChainModal.tsx create mode 100644 apps/namadillo/src/App/Transfer/SelectProviderModal.tsx create mode 100644 apps/namadillo/src/App/Transfer/SelectedAsset.tsx create mode 100644 apps/namadillo/src/App/Transfer/SelectedChain.tsx create mode 100644 apps/namadillo/src/App/Transfer/TransferDestination.tsx create mode 100644 apps/namadillo/src/App/Transfer/TransferModule.tsx create mode 100644 apps/namadillo/src/App/Transfer/TransferSource.tsx create mode 100644 apps/namadillo/src/App/Transfer/__mocks__/chains.ts create mode 100644 apps/namadillo/src/App/Transfer/__mocks__/providers.ts create mode 100644 apps/namadillo/src/App/Transfer/__tests__/AvailableAmountFooter.test.tsx create mode 100644 apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx create mode 100644 apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx create mode 100644 apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx create mode 100644 apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx create mode 100644 apps/namadillo/src/App/Transfer/assets/namada-shielded.svg create mode 100644 apps/namadillo/src/App/Transfer/assets/namada-transparent.svg diff --git a/apps/namadillo/src/App/Common/SelectModal.tsx b/apps/namadillo/src/App/Common/SelectModal.tsx new file mode 100644 index 000000000..6e92dcc7f --- /dev/null +++ b/apps/namadillo/src/App/Common/SelectModal.tsx @@ -0,0 +1,40 @@ +import { Modal } from "@namada/components"; +import clsx from "clsx"; +import React from "react"; +import { IoClose } from "react-icons/io5"; +import { ModalTransition } from "./ModalTransition"; + +type SelectModalProps = { + children: React.ReactNode; + title: React.ReactNode; + onClose: () => void; +}; + +export const SelectModal = ({ + children, + title, + onClose, +}: SelectModalProps): JSX.Element => { + return ( + + +
+
+ {title} + + + +
+ {children} +
+
+
+ ); +}; diff --git a/apps/namadillo/src/App/Common/TabSelector.tsx b/apps/namadillo/src/App/Common/TabSelector.tsx new file mode 100644 index 000000000..29e0eea6b --- /dev/null +++ b/apps/namadillo/src/App/Common/TabSelector.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import { twMerge } from "tailwind-merge"; +type TabSelectorItem = { + text: React.ReactNode; + id: string; + className?: string; +}; + +type TabSelectorProps = { + items: TabSelectorItem[]; + active: string; + onChange: (item: TabSelectorItem) => void; +}; + +export const TabSelector = ({ + items, + active, + onChange, +}: TabSelectorProps): JSX.Element => { + return ( + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx new file mode 100644 index 000000000..e4a50c47a --- /dev/null +++ b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx @@ -0,0 +1,54 @@ +import { ActionButton, Currency } from "@namada/components"; +import { KnownCurrencies } from "@namada/utils"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; + +type AvailableAmountFooterProps = { + availableAmount?: BigNumber; + currency?: keyof typeof KnownCurrencies; + onClickMax?: () => void; +}; + +export const AvailableAmountFooter = ({ + availableAmount, + currency, + onClickMax, +}: AvailableAmountFooterProps): JSX.Element => { + if (!currency || availableAmount === undefined) { + return <>; + } + + return ( +
+ + Available: + + + + {onClickMax && ( + + Max + + )} + +
+ ); +}; diff --git a/apps/namadillo/src/App/Transfer/ChainCard.tsx b/apps/namadillo/src/App/Transfer/ChainCard.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx new file mode 100644 index 000000000..b5935dd22 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx @@ -0,0 +1,21 @@ +import { ActionButton } from "@namada/components"; + +type ConnectProviderButtonProps = { + onClick?: () => void; +}; + +export const ConnectProviderButton = ({ + onClick, +}: ConnectProviderButtonProps): JSX.Element => { + return ( + + Connect Wallet + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx b/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx new file mode 100644 index 000000000..981386511 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx @@ -0,0 +1,35 @@ +import { Input, Stack } from "@namada/components"; + +type CustomAddressFormProps = { + onChangeAddress?: (address: string | undefined) => void; + customAddress?: string; + memo?: string; + onChangeMemo?: (address: string) => void; +}; + +export const CustomAddressForm = ({ + customAddress, + onChangeAddress, + memo, + onChangeMemo, +}: CustomAddressFormProps): JSX.Element => { + return ( + + {onChangeAddress && ( + onChangeAddress(e.target.value)} + /> + )} + {onChangeMemo && ( + onChangeMemo(e.target.value)} + placeholder="Required for centralized exchanges" + /> + )} + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/EmptyResourceIcon.tsx b/apps/namadillo/src/App/Transfer/EmptyResourceIcon.tsx new file mode 100644 index 000000000..555021e8c --- /dev/null +++ b/apps/namadillo/src/App/Transfer/EmptyResourceIcon.tsx @@ -0,0 +1,20 @@ +import clsx from "clsx"; +import { twMerge } from "tailwind-merge"; +type EmptyResourceProps = { className?: string }; + +export const EmptyResourceIcon = ({ + className = "", +}: EmptyResourceProps): JSX.Element => { + return ( + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx b/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx new file mode 100644 index 000000000..088f5a72c --- /dev/null +++ b/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx @@ -0,0 +1,9 @@ +import { TransferModule } from "./TransferModule"; + +export const IBCFromNamadaModule = (): JSX.Element => { + return ( +
+ {}} /> +
+ ); +}; diff --git a/apps/namadillo/src/App/Transfer/IBCTransfers.tsx b/apps/namadillo/src/App/Transfer/IBCTransfers.tsx new file mode 100644 index 000000000..3a551ec52 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/IBCTransfers.tsx @@ -0,0 +1,12 @@ +import { Panel } from "@namada/components"; +import { IBCFromNamadaModule } from "./IBCFromNamadaModule"; + +export const IBCTransfers = (): JSX.Element => { + return ( +
+ + + +
+ ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectAssetsModal.tsx b/apps/namadillo/src/App/Transfer/SelectAssetsModal.tsx new file mode 100644 index 000000000..8faf84a6c --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectAssetsModal.tsx @@ -0,0 +1,15 @@ +import { SelectModal } from "App/Common/SelectModal"; + +type SelectItemsModalProps = { + onClose: () => void; +}; + +export const SelectAssetsModal = ({ + onClose, +}: SelectItemsModalProps): JSX.Element => { + return ( + + <> + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectChainModal.tsx b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx new file mode 100644 index 000000000..61fed101b --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx @@ -0,0 +1,34 @@ +import { Stack } from "@namada/components"; +import { SelectModal } from "App/Common/SelectModal"; +import clsx from "clsx"; +import { Chain } from "types"; + +type SelectChainModalProps = { + onClose: () => void; + chains: Chain[]; +}; + +export const SelectChainModal = ({ + onClose, + chains, +}: SelectChainModalProps): JSX.Element => { + return ( + + + {chains.map((chain) => ( +
  • + +
  • + ))} +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx b/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx new file mode 100644 index 000000000..88fcb4eb7 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx @@ -0,0 +1,43 @@ +import { SelectModal } from "App/Common/SelectModal"; +import { Provider } from "types"; + +type SelectProviderModalProps = { + onClose: () => void; + providers: Provider[]; + onConnect: (provider: Provider) => void; +}; + +export const SelectProviderModal = ({ + onClose, + onConnect, + providers, +}: SelectProviderModalProps): JSX.Element => { + return ( + +
      + {providers.map((provider: Provider, index) => ( +
    • + +
    • + ))} +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectedAsset.tsx b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx new file mode 100644 index 000000000..4f86e205b --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx @@ -0,0 +1,51 @@ +import clsx from "clsx"; +import { GoChevronDown } from "react-icons/go"; +import { Asset, Chain } from "types"; +import { EmptyResourceIcon } from "./EmptyResourceIcon"; + +type SelectedAssetProps = { + chain?: Chain; + asset?: Asset; + onClick?: () => void; +}; + +export const SelectedAsset = ({ + chain, + asset, + onClick, +}: SelectedAssetProps): JSX.Element => { + const selectorClassList = clsx( + `flex items-center gap-2.5 text-lg text-white font-light cursor-pointer uppercase` + ); + + return ( + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectedChain.tsx b/apps/namadillo/src/App/Transfer/SelectedChain.tsx new file mode 100644 index 000000000..c2f923d0e --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectedChain.tsx @@ -0,0 +1,53 @@ +import clsx from "clsx"; +import { GoChevronDown } from "react-icons/go"; +import { Chain, Provider } from "types"; +import { EmptyResourceIcon } from "./EmptyResourceIcon"; + +type SelectedChainProps = { + chain?: Chain; + provider?: Provider; + onClick?: () => void; +}; + +export const SelectedChain = ({ + chain, + provider, + onClick, +}: SelectedChainProps): JSX.Element => { + const selectorClassList = clsx( + `flex items-center gap-2.5 text-white font-light cursor-pointer` + ); + + return ( + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/Transfer.tsx b/apps/namadillo/src/App/Transfer/Transfer.tsx index 6204782b1..836b5c9f6 100644 --- a/apps/namadillo/src/App/Transfer/Transfer.tsx +++ b/apps/namadillo/src/App/Transfer/Transfer.tsx @@ -1,4 +1,5 @@ import { Route, Routes } from "react-router-dom"; +import { IBCTransfers } from "./IBCTransfers"; import { ShieldAll } from "./ShieldAll"; import TransferRoutes from "./routes"; @@ -9,6 +10,10 @@ export const Transfer: React.FC = () => ( path={TransferRoutes.shieldAll().toString()} element={} /> + } + /> ); diff --git a/apps/namadillo/src/App/Transfer/TransferDestination.tsx b/apps/namadillo/src/App/Transfer/TransferDestination.tsx new file mode 100644 index 000000000..a9f454622 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/TransferDestination.tsx @@ -0,0 +1,103 @@ +import { NamCurrency } from "App/Common/NamCurrency"; +import { TabSelector } from "App/Common/TabSelector"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; +import { Chain, Provider } from "types"; +import namadaShieldedSvg from "./assets/namada-shielded.svg"; +import namadaTransparentSvg from "./assets/namada-transparent.svg"; +import { CustomAddressForm } from "./CustomAddressForm"; +import { SelectedChain } from "./SelectedChain"; + +type TransferDestinationProps = { + isShielded?: boolean; + onChangeShielded?: (isShielded: boolean) => void; + chain?: Chain; + provider?: Provider; + className?: string; + transactionFee?: BigNumber; + customAddressActive?: boolean; + onToggleCustomAddress?: (isActive: boolean) => void; + onChangeAddress?: (address: string | undefined) => void; + address?: string; + memo?: string; + onChangeMemo?: (address: string) => void; +}; + +const parseChainInfo = ( + chain?: Chain, + isShielded?: boolean +): Chain | undefined => { + if (chain?.name !== "Namada") { + return chain; + } + return { + ...chain, + name: isShielded ? "Namada Shielded" : "Namada Transparent", + iconUrl: isShielded ? namadaShieldedSvg : namadaTransparentSvg, + }; +}; + +export const TransferDestination = ({ + chain, + provider, + isShielded, + onChangeShielded, + transactionFee, + customAddressActive, + onToggleCustomAddress, + address, + onChangeAddress, + memo, + onChangeMemo, +}: TransferDestinationProps): JSX.Element => { + return ( +
    + {onChangeShielded && chain?.name === "Namada" && ( + onChangeShielded(!isShielded)} + /> + )} + + {onToggleCustomAddress && ( + onToggleCustomAddress(!customAddressActive)} + items={[ + { id: "my-address", text: "My Address", className: "text-white" }, + { id: "custom", text: "Custom Address", className: "text-white" }, + ]} + /> + )} + + + + {customAddressActive && ( + + )} + + {transactionFee && ( +
    + Transaction Fee + +
    + )} +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/TransferModule.tsx b/apps/namadillo/src/App/Transfer/TransferModule.tsx new file mode 100644 index 000000000..04d198e97 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/TransferModule.tsx @@ -0,0 +1,85 @@ +import { Stack } from "@namada/components"; +import BigNumber from "bignumber.js"; +import { useState } from "react"; +import { Asset, Chain } from "types"; +import { SelectProviderModal } from "./SelectProviderModal"; +import { TransferDestination } from "./TransferDestination"; +import { TransferSource } from "./TransferSource"; + +type TransferModuleProps = { + isConnected: boolean; + sourceChain?: Chain; + onChangeSourceChain?: () => void; + destinationChain?: Chain; + onChangeDestinationChain?: (chain: Chain) => void; + selectedAsset?: Asset; + onChangeSelectedAsset?: (asset: Asset | undefined) => void; + amount?: BigNumber; + onChangeAmount?: (amount: BigNumber) => void; + isShielded?: boolean; + onChangeShielded?: (isShielded: boolean) => void; + enableCustomAddress?: boolean; + onSubmitTransfer: () => void; +}; + +export const TransferModule = ({ + isConnected, + selectedAsset, + sourceChain, + destinationChain, + isShielded, + onChangeShielded, + enableCustomAddress, +}: TransferModuleProps): JSX.Element => { + const [providerSelectorModalOpen, setProviderSelectorModalOpen] = + useState(false); + const [chainSelectorModalOpen, setChainSelectorModalOpen] = useState(false); + const [assetSelectorModalOpen, setAssetSelectorModalOpen] = useState(false); + const [customAddressActive, setCustomAddressActive] = useState(false); + const [memo, setMemo] = useState(""); + const [customAddress, setCustomAddress] = useState(""); + const [amount, setAmount] = useState(new BigNumber(0)); + + return ( + <> +
    + + setProviderSelectorModalOpen(true)} + openChainSelector={() => setChainSelectorModalOpen(true)} + openAssetSelector={() => setAssetSelectorModalOpen(true)} + amount={amount} + onChangeAmount={(e) => + setAmount(e.target.value || new BigNumber(0)) + } + /> + + {chainSelectorModalOpen &&
    } + {assetSelectorModalOpen &&
    } + +
    + {providerSelectorModalOpen && ( + setProviderSelectorModalOpen(false)} + onConnect={() => {}} + /> + )} + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/TransferSource.tsx b/apps/namadillo/src/App/Transfer/TransferSource.tsx new file mode 100644 index 000000000..3e52ca813 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/TransferSource.tsx @@ -0,0 +1,64 @@ +import { AmountInput, ChangeAmountEvent } from "@namada/components"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; +import { Asset, Chain, Provider } from "types"; +import { AvailableAmountFooter } from "./AvailableAmountFooter"; +import { ConnectProviderButton } from "./ConnectProviderButton"; +import { SelectedAsset } from "./SelectedAsset"; +import { SelectedChain } from "./SelectedChain"; + +export type TransferSourceProps = { + isConnected: boolean; + provider?: Provider; + asset?: Asset; + chain?: Chain; + openChainSelector?: () => void; + openAssetSelector?: () => void; + openProviderSelector?: () => void; + amount?: BigNumber; + onChangeAmount?: ChangeAmountEvent; +}; + +export const TransferSource = ({ + chain, + asset, + provider, + openProviderSelector, + openChainSelector, + openAssetSelector, + amount, + onChangeAmount, +}: TransferSourceProps): JSX.Element => { + return ( +
    +
    + + {!provider && } +
    +
    +
    + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/__mocks__/chains.ts b/apps/namadillo/src/App/Transfer/__mocks__/chains.ts new file mode 100644 index 000000000..da2ad5502 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__mocks__/chains.ts @@ -0,0 +1,13 @@ +import { Chain } from "types"; + +export const namadaChainMock: Chain = { + chainId: "test", + name: "Namada", + iconUrl: "namada-icon", +}; + +export const randomChainMock: Chain = { + chainId: "test", + name: "TestChain", + iconUrl: "testchain-icon", +}; diff --git a/apps/namadillo/src/App/Transfer/__mocks__/providers.ts b/apps/namadillo/src/App/Transfer/__mocks__/providers.ts new file mode 100644 index 000000000..af2cd0157 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__mocks__/providers.ts @@ -0,0 +1,12 @@ +import { Provider } from "types"; + +export const providerMock: Provider = { + name: "Keplr", + iconUrl: "test.svg", + connected: false, +}; + +export const providerConnectedMock: Provider = { + ...providerMock, + connected: true, +}; diff --git a/apps/namadillo/src/App/Transfer/__tests__/AvailableAmountFooter.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/AvailableAmountFooter.test.tsx new file mode 100644 index 000000000..088ff37b3 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/AvailableAmountFooter.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import BigNumber from "bignumber.js"; +import { AvailableAmountFooter } from "../AvailableAmountFooter"; + +describe("Component: AvailableAmountFooter", () => { + it("should render an empty tag when no available amount is provided", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should render with correct information and behaviour enabled", () => { + const callback = jest.fn(); + render( + + ); + const amount = screen.getByText("1,234"); + const button = screen.getByRole("button"); + expect(amount.parentNode?.textContent).toContain("1,234.456 NAM"); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not display MAX button when no callback was provided", () => { + render( + + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("should display disabled button when the amount is zero", () => { + const callback = jest.fn(); + render( + + ); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx new file mode 100644 index 000000000..5ebe93cc1 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx @@ -0,0 +1,71 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { SelectedAsset } from "App/Transfer/SelectedAsset"; // Adjust the path accordingly +import { Asset, Chain } from "types"; // Adjust the path accordingly + +describe("SelectedAsset", () => { + const mockChain: Chain = { + chainId: "1", + name: "Ethereum", + iconUrl: "https://example.com/ethereum-icon.png", + }; + + const mockAsset: Partial = { + name: "Ethereum", + denomination: "ETH", + iconUrl: "https://example.com/eth-icon.png", + }; + + it("renders with no chain and disables the button", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); + + it("renders with no asset selected", () => { + const mockFn = jest.fn(); + render(); + + const button = screen.getByRole("button"); + expect(button).toBeEnabled(); + + const assetLabel = screen.getByText(/asset/i); + expect(assetLabel).toBeInTheDocument(); + + fireEvent.click(button); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it("renders with asset selected", () => { + const handleClick = jest.fn(); + render( + + ); + + const button = screen.getByRole("button"); + expect(button).toBeEnabled(); + + const assetDenomination = screen.getByText(mockAsset.denomination!); + expect(assetDenomination).toBeInTheDocument(); + + const assetImage = screen.getByAltText(`${mockAsset.name} image`); + expect(assetImage).toHaveStyle( + `background-image: url(${mockAsset.iconUrl})` + ); + + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("does not call onClick when the button is disabled", () => { + const handleClick = jest.fn(); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx new file mode 100644 index 000000000..9008be967 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx @@ -0,0 +1,67 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { SelectedChain } from "App/Transfer/SelectedChain"; +import { Chain } from "types"; +import { providerConnectedMock } from "../__mocks__/providers"; + +describe("Component: SelectedChain", () => { + const mockChain: Chain = { + chainId: "chain-id", + name: "Ethereum", + iconUrl: "https://example.com/ethereum-icon.png", + }; + + it("renders disabled with no provider selected", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); + + it("renders empty when chain is passed, but provider is disconnected", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(button.getAttribute("aria-description")).toMatch(/no chain/i); + }); + + it("renders correctly with no chain selected", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + expect(button.getAttribute("aria-description")).toMatch(/no chain/i); + }); + + it("renders correctly with chain selected", () => { + render( + + ); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button.getAttribute("aria-description")).toContain(mockChain.name); + + const chainName = screen.getByText(mockChain.name); + expect(chainName).toBeInTheDocument(); + + const chainImage = screen.getByAltText(`${mockChain.name}`, { + exact: false, + }); + expect(chainImage).toBeInTheDocument(); + expect(chainImage).toHaveAttribute( + "style", + `background-image: url(${mockChain.iconUrl});` + ); + }); + + it("calls onClick when the component is clicked", () => { + const handleClick = jest.fn(); + render( + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx new file mode 100644 index 000000000..fee5a7878 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { + namadaChainMock, + randomChainMock, +} from "App/Transfer/__mocks__/chains"; +import { TransferDestination } from "App/Transfer/TransferDestination"; +import BigNumber from "bignumber.js"; +import { providerMock } from "../__mocks__/providers"; + +describe("TransferDestination", () => { + it("should render the component with the default props", () => { + render(); + expect(screen.getByText(/select chain/i)).toBeInTheDocument(); + }); + + it("should render the TabSelector for shielded/transparent when onChangeShielded is provided", () => { + render( + + ); + expect(screen.getByText("Shielded")).toBeInTheDocument(); + expect(screen.getByText("Transparent")).toBeInTheDocument(); + }); + + it("should render a yellow border when transfer is shielded", () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain("border-yellow"); + }); + + it("should render nothing related to shielding when provided chain is not Namada", () => { + render( + + ); + expect(screen.queryByText(/shielded/i)).not.toBeInTheDocument(); + }); + + it("should render correct chain name when shielded transfer is set", () => { + render( + + ); + expect(screen.getByText(/namada shielded/i)).toBeInTheDocument(); + }); + + it("should render correct chain name when transparent transfer is set", () => { + render( + + ); + expect(screen.getByText(/namada transparent/i)).toBeInTheDocument(); + }); + + it("should toggle between shielded and transparent", () => { + const onChangeShieldedMock = jest.fn(); + render( + + ); + const transparentButton = screen.getByText("Transparent"); + fireEvent.click(transparentButton); + expect(onChangeShieldedMock).toHaveBeenCalledWith(false); + }); + + it("should toggle between custom and my address when onToggleCustomAddress is provided", () => { + const onToggleCustomAddressMock = jest.fn(); + render( + + ); + const customAddressButton = screen.getByText("Custom Address"); + fireEvent.click(customAddressButton); + expect(onToggleCustomAddressMock).toHaveBeenCalledWith(true); + }); + + it("should display the transaction fee if provided", () => { + const fee = new BigNumber(0.01); + render(); + const transactionFee = screen.getByText("Transaction Fee"); + expect(transactionFee).toBeInTheDocument(); + expect(transactionFee.parentNode?.textContent).toContain("0.01"); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx new file mode 100644 index 000000000..b21c3c2c0 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { + TransferSource, + TransferSourceProps, +} from "App/Transfer/TransferSource"; +import BigNumber from "bignumber.js"; +import { namadaChainMock } from "../__mocks__/chains"; +import { providerConnectedMock } from "../__mocks__/providers"; + +describe("Component: TransferSource", () => { + it("should render the component with the default props", () => { + render( + + ); + expect(screen.getByText("Connect Wallet")).toBeInTheDocument(); + expect(screen.getByText(/select chain/i)).toBeInTheDocument(); + }); + + const setup = (props: Partial = {}): void => { + render( + + ); + }; + + const getEmptyChain = (): HTMLElement => { + return screen.getByText(/select chain/i); + }; + + const getEmptyAsset = (): HTMLElement => { + return screen.getByText(/asset/i); + }; + + it("should call onConnectProvider when Connect Wallet button is clicked", () => { + const onConnectProviderMock = jest.fn(); + setup({ openProviderSelector: onConnectProviderMock }); + fireEvent.click(screen.getByText("Connect Wallet")); + expect(onConnectProviderMock).toHaveBeenCalled(); + }); + + it("should call openChainSelector when the SelectedChain is clicked", () => { + const openChainSelectorMock = jest.fn(); + setup({ + openChainSelector: openChainSelectorMock, + provider: providerConnectedMock, + }); + const chain = getEmptyChain(); + fireEvent.click(chain); + expect(openChainSelectorMock).toHaveBeenCalled(); + }); + + it("should render controls disabled when chain is not defined", () => { + const openAssetSelectorMock = jest.fn(); + setup({ openAssetSelector: openAssetSelectorMock }); + const assetControl = getEmptyAsset(); + fireEvent.click(assetControl); + expect(openAssetSelectorMock).not.toHaveBeenCalled(); + const amountInput = screen.getByDisplayValue("0"); + expect(amountInput).toBeDisabled(); + }); + + it("should call openAssetSelector when the SelectedAsset is clicked", () => { + const openAssetSelectorMock = jest.fn(); + setup({ openAssetSelector: openAssetSelectorMock, chain: namadaChainMock }); + const assetControl = getEmptyAsset(); + fireEvent.click(assetControl); + expect(openAssetSelectorMock).toHaveBeenCalled(); + }); + + it("should render the amount input with the correct value", () => { + const amount = new BigNumber(100); + setup({ amount }); + const amountInput = screen.getByDisplayValue("100"); + expect(amountInput).toBeInTheDocument(); + }); + + it("should call onChangeAmount when the amount input is changed", () => { + const onChangeAmountMock = jest.fn(); + setup({ amount: new BigNumber(0), onChangeAmount: onChangeAmountMock }); + const amountInput = screen.getByDisplayValue("0"); + fireEvent.change(amountInput, { target: { value: new BigNumber("200") } }); + expect(onChangeAmountMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/assets/namada-shielded.svg b/apps/namadillo/src/App/Transfer/assets/namada-shielded.svg new file mode 100644 index 000000000..8d1e160ab --- /dev/null +++ b/apps/namadillo/src/App/Transfer/assets/namada-shielded.svg @@ -0,0 +1 @@ + diff --git a/apps/namadillo/src/App/Transfer/assets/namada-transparent.svg b/apps/namadillo/src/App/Transfer/assets/namada-transparent.svg new file mode 100644 index 000000000..5218e65ad --- /dev/null +++ b/apps/namadillo/src/App/Transfer/assets/namada-transparent.svg @@ -0,0 +1 @@ + diff --git a/apps/namadillo/src/App/Transfer/routes.ts b/apps/namadillo/src/App/Transfer/routes.ts index 0d95c6f87..49d772d99 100644 --- a/apps/namadillo/src/App/Transfer/routes.ts +++ b/apps/namadillo/src/App/Transfer/routes.ts @@ -6,10 +6,13 @@ const routeOutput = createRouteOutput(index); export const overview = (): RouteOutput => routeOutput(`/`); +export const ibcTransfer = (): RouteOutput => routeOutput(`/ibc`); + export const shieldAll = (): RouteOutput => routeOutput(`/shield-all`); export default { index, overview, shieldAll, + ibcTransfer, }; diff --git a/apps/namadillo/src/types.d.ts b/apps/namadillo/src/types.d.ts index bcc61d059..8f18d4a37 100644 --- a/apps/namadillo/src/types.d.ts +++ b/apps/namadillo/src/types.d.ts @@ -166,3 +166,24 @@ export type ToastNotification = { export type ToastNotificationEntryFilter = ( notification: ToastNotification ) => boolean; + +export type Provider = { + name: string; + iconUrl: string; + connected: boolean; +}; + +export type Chain = { + chainId: string; + name: string; + iconUrl: string; +}; + +export type Asset = { + chain: Chain; + name: string; + iconUrl: string; + denomination: string; + minimalDenomination: string; + decimals: number; +}; diff --git a/packages/components/src/AmountInput.tsx b/packages/components/src/AmountInput.tsx index 0f3b93d95..7d72d80ff 100644 --- a/packages/components/src/AmountInput.tsx +++ b/packages/components/src/AmountInput.tsx @@ -9,9 +9,11 @@ export type BigNumberElement = Omit & { value?: BigNumber; }; +export type ChangeAmountEvent = ChangeEventHandler; + type Props = Omit & { value?: BigNumber; - onChange?: ChangeEventHandler; + onChange?: ChangeAmountEvent; maxDecimalPlaces?: number; min?: string | number | BigNumber; max?: string | number | BigNumber; diff --git a/packages/components/src/Input.tsx b/packages/components/src/Input.tsx index c724efb12..71e51d69e 100644 --- a/packages/components/src/Input.tsx +++ b/packages/components/src/Input.tsx @@ -171,7 +171,11 @@ export const Input = ({ return ( ); diff --git a/packages/components/src/Modal.tsx b/packages/components/src/Modal.tsx index 9844298b9..23dc28954 100644 --- a/packages/components/src/Modal.tsx +++ b/packages/components/src/Modal.tsx @@ -45,6 +45,7 @@ export const Modal = ({ />