diff --git a/gnt2-migration-ui/src/ui/Account.tsx b/gnt2-migration-ui/src/ui/Account.tsx index 90a0855..3ba75cd 100644 --- a/gnt2-migration-ui/src/ui/Account.tsx +++ b/gnt2-migration-ui/src/ui/Account.tsx @@ -7,6 +7,11 @@ import {useAsyncEffect} from './hooks/useAsyncEffect'; import {useProperty} from './hooks/useProperty'; import {useSnackbar} from './hooks/useSnackbar'; import Jazzicon, {jsNumberForAddress} from 'react-jazzicon'; +import {Modal} from './Modal'; +import {useModal} from './hooks/useModal'; +import {TransactionProgress} from './TransactionProgres'; +import {CTAButton} from './commons/CTAButton'; + export const Account = () => { const [balance, setBalance] = useState(undefined); @@ -16,6 +21,10 @@ export const Account = () => { const [depositTokensBalance, setDepositTokensBalance] = useState(undefined); const [refresh, setRefresh] = useState(false); const [transactionHash, setTransactionHash] = useState(''); + const [errorMessage, setErrorMessage] = useState(undefined); + const [txInProgress, setTxInProgress] = useState(false); + const [isTransactionModalVisible, openTransactionModal, closeTransactionModal] = useModal(); + const {accountService, tokensService, contractAddressService, connectionService} = useServices(); const tokenAddresses = useProperty(contractAddressService.golemNetworkTokenAddress); @@ -31,12 +40,19 @@ export const Account = () => { }, [refresh, account, tokenAddresses]); const migrateTokens = async () => { + setTransactionHash(undefined); + setErrorMessage(undefined); try { + openTransactionModal(); + setTxInProgress(true); const tx = await tokensService.migrateAllTokens(account); setTransactionHash(tx); + setTxInProgress(false); setRefresh(!refresh); } catch (e) { show(e.message); + setErrorMessage(e.message); + setTxInProgress(false); } }; @@ -59,10 +75,13 @@ export const Account = () => { {depositTokensBalance &&
{format(depositTokensBalance)}
}
Your ETH balance:
{balance &&
{format(balance, 4)}
} - + Migrate - - {transactionHash &&
{transactionHash}
} + + {isTransactionModalVisible && + + + } ); }; @@ -75,20 +94,3 @@ const JazziconAddress = styled.div` const Address = styled.div` margin-left: 8px; `; - -const Migrate = styled.button` - background-color: #181EA9; - border: none; - color: white; - padding: 15px 32px; - margin: 12px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - border-radius: 8px; - &:disabled { - opacity: 0.3; - background: grey; - } -`; diff --git a/gnt2-migration-ui/src/ui/App.tsx b/gnt2-migration-ui/src/ui/App.tsx index cc9e834..16db52b 100644 --- a/gnt2-migration-ui/src/ui/App.tsx +++ b/gnt2-migration-ui/src/ui/App.tsx @@ -41,9 +41,8 @@ const App: React.FC = () => { export default hot(App); const Body = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 90vh; `; diff --git a/gnt2-migration-ui/src/ui/Modal.tsx b/gnt2-migration-ui/src/ui/Modal.tsx new file mode 100644 index 0000000..b4b54c1 --- /dev/null +++ b/gnt2-migration-ui/src/ui/Modal.tsx @@ -0,0 +1,69 @@ +import React, {ReactNode} from 'react'; +import styled from 'styled-components'; +import {Spinner} from './Spinner'; + +export interface ModalProps { + children: ReactNode; + onClose: () => void; + inProgress: boolean; +} + +export const Modal = ({children, onClose, inProgress}: ModalProps) => + ( + + {inProgress + ? + : + } + {children} + + ); + +const ModalBackdrop = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(12, 35, 64, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +`; + +const ModalBody = styled.div` + position: relative; + padding: 30px 40px; + min-width: 400px; + min-height: 200px; + max-height: 95%; + overflow-y: scroll; + background-color: #FFFFFF; + border-radius: 8px; + box-shadow: 0 20px 50px -10px rgba(0, 0, 0, 0.2); +`; + +const CloseButton = styled.div` + position: absolute; + top: 30px; + right: 30px; + width: 20px; + height: 20px; + + &::before, &::after { + content: ''; + width: 17px; + height: 2px; + border-radius: 1px; + display: block; + background-color: #1c1c1c; + transform: translate(-50%, -50%) rotate(45deg); + position: absolute; + top: 10px; + left: 10px; + } + &::after { + transform: translate(-50%, -50%) rotate(135deg); + } +`; diff --git a/gnt2-migration-ui/src/ui/Spinner.tsx b/gnt2-migration-ui/src/ui/Spinner.tsx new file mode 100644 index 0000000..19b9bb3 --- /dev/null +++ b/gnt2-migration-ui/src/ui/Spinner.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import styled from 'styled-components'; + +export const Spinner = () => ( + +); + +const Loader = styled.div` + position: absolute; + top: 30px; + right: 30px; + width: 1.5rem; + height: 1.5rem; + margin: 1.5rem; + border-radius: 50%; + border: 0.3rem solid #181EA9; + border-top-color: #FFFFFF; + animation: 1.5s spin infinite linear; + + &.multi { + border-bottom-color: #FFFFFF; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +`; diff --git a/gnt2-migration-ui/src/ui/TransactionProgres.tsx b/gnt2-migration-ui/src/ui/TransactionProgres.tsx new file mode 100644 index 0000000..d65074c --- /dev/null +++ b/gnt2-migration-ui/src/ui/TransactionProgres.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import styled from 'styled-components'; +import {CTAButton} from './commons/CTAButton'; + +export const TransactionProgress = (props: { transactionHash: string | undefined, errorMessage: string | undefined }) => <> + Transaction in progress + + View transaction + details + + {props.errorMessage &&
{props.errorMessage}
} +; + +const Title = styled.p` + font-style: normal; + font-weight: bold; + font-size: 24px; + line-height: 29px; + color: #181EA9; +`; diff --git a/gnt2-migration-ui/src/ui/commons/CTAButton.ts b/gnt2-migration-ui/src/ui/commons/CTAButton.ts new file mode 100644 index 0000000..4e60c00 --- /dev/null +++ b/gnt2-migration-ui/src/ui/commons/CTAButton.ts @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +export const CTAButton = styled.button` + background-color: #181EA9; + border: none; + color: white; + padding: 15px 32px; + margin: 12px 0; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + border-radius: 8px; + &:disabled { + opacity: 0.3; + background: grey; + } +`; diff --git a/gnt2-migration-ui/src/ui/hooks/useModal.ts b/gnt2-migration-ui/src/ui/hooks/useModal.ts new file mode 100644 index 0000000..695377b --- /dev/null +++ b/gnt2-migration-ui/src/ui/hooks/useModal.ts @@ -0,0 +1,9 @@ +import {useCallback, useState} from 'react'; + +export const useModal = (initialMode = false): [boolean, () => void, () => void] => { + const [isModalVisible, setIsModalVisible] = useState(initialMode); + const closeModal = useCallback(() => setIsModalVisible(false), []); + const openModal = useCallback(() => setIsModalVisible(true), []); + + return [isModalVisible, openModal, closeModal]; +}; diff --git a/gnt2-migration-ui/test/Account.test.tsx b/gnt2-migration-ui/test/Account.test.tsx new file mode 100644 index 0000000..ebf70b3 --- /dev/null +++ b/gnt2-migration-ui/test/Account.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import {fireEvent, render, wait, waitForElement} from '@testing-library/react'; +import {createMockProvider} from 'ethereum-waffle'; +import {Account} from '../src/ui/Account'; +import {ServiceContext} from '../src/ui/useServices'; +import {Services} from '../src/services'; +import chai, {expect} from 'chai'; +import chaiDom from 'chai-dom'; +import {createTestServices} from './helpers/testServices'; +import {TransactionDenied} from '../src/errors'; +import sinon from 'sinon'; +import {SnackbarProvider} from '../src/ui/Snackbar/SnackbarProvider'; + +chai.use(chaiDom); + +async function renderAccount(services: Services) { + return render( + + + + + + ); +} + +describe('Account page', () => { + + let services: Services; + + beforeEach(async () => { + services = await createTestServices(createMockProvider()); + }); + + it('shows balances', async () => { + const {getByTestId} = await renderAccount(services); + + expect(await waitForElement(() => getByTestId('ETH-balance'))).to.have.text('9999999999999999.9721'); + expect(await waitForElement(() => getByTestId('GNT-balance'))).to.have.text('140000000.000'); + expect(await waitForElement(() => getByTestId('NGNT-balance'))).to.have.text('0.000'); + expect(await waitForElement(() => getByTestId('GNTB-balance'))).to.have.text('9999900.000'); + expect(await waitForElement(() => getByTestId('deposit'))).to.have.text('100.000'); + }); + + it('shows migrated tokens', async () => { + const {getByTestId} = await renderAccount(services); + + fireEvent.click(getByTestId('button')); + + await wait(() => { + expect(getByTestId('NGNT-balance')).to.have.text('140000000.000'); + expect(getByTestId('GNT-balance')).to.have.text('0.000'); + }); + }); + + it('shows modal on migrate', async () => { + const {getByTestId} = await renderAccount(services); + + fireEvent.click(getByTestId('button')); + + await wait(() => { + expect(getByTestId('modal')).to.exist; + expect(getByTestId('etherscan-button')).to.have.text('View transaction details'); + expect(getByTestId('etherscan-button')).to.not.have.attr('disabled'); + expect(getByTestId('etherscan-link')).to.have.attr('href').match(/https:\/\/rinkeby.etherscan.io\/address\/0x[0-9a-fA-F]{64}/); + }); + }); + + it('shows error in modal with when user denied transaction', async () => { + sinon.stub(services.tokensService, 'migrateAllTokens').rejects(new TransactionDenied(new Error())); + const {getByTestId} = await renderAccount(services); + + fireEvent.click(getByTestId('button')); + + await wait(() => { + expect(getByTestId('modal')).to.exist; + expect(getByTestId('etherscan-button')).to.have.attr('disabled'); + expect(getByTestId('error-message')).to.have.text('User denied transaction signature.'); + }); + }); +}); diff --git a/gnt2-migration-ui/test/AccountBalances.test.tsx b/gnt2-migration-ui/test/AccountBalances.test.tsx deleted file mode 100644 index 1ce6975..0000000 --- a/gnt2-migration-ui/test/AccountBalances.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import {fireEvent, render, wait, waitForElement} from '@testing-library/react'; -import {createMockProvider} from 'ethereum-waffle'; -import {Account} from '../src/ui/Account'; -import {ServiceContext} from '../src/ui/useServices'; -import {Services} from '../src/services'; -import chai, {expect} from 'chai'; -import chaiDom from 'chai-dom'; -import {createTestServices} from './helpers/testServices'; - -chai.use(chaiDom); - -describe('Account page', () => { - - let services: Services; - - beforeEach(async () => { - services = await createTestServices(createMockProvider()); - }); - - it('shows balances', async () => { - const {getByTestId} = await render( - - - - ); - - expect(await waitForElement(() => getByTestId('ETH-balance'))).to.have.text('9999999999999999.9721'); - expect(await waitForElement(() => getByTestId('GNT-balance'))).to.have.text('140000000.000'); - expect(await waitForElement(() => getByTestId('NGNT-balance'))).to.have.text('0.000'); - expect(await waitForElement(() => getByTestId('GNTB-balance'))).to.have.text('9999900.000'); - expect(await waitForElement(() => getByTestId('deposit'))).to.have.text('100.000'); - }); - - it('shows migrated tokens', async () => { - const {queryByTestId, getByTestId} = await render( - - - - ); - - fireEvent.click(getByTestId('button')); - - await wait(() => { - expect(queryByTestId('NGNT-balance')).to.have.text('140000000.000'); - expect(queryByTestId('GNT-balance')).to.have.text('0.000'); - }); - }); -});