diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index 8580be8774..73415ae19c 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -15,8 +15,7 @@ "Add a Lisk ID": "Add a Lisk ID", "Add new": "Add new", "Add this account to your dashboard to keep track of its balance, and use it as a bookmark in the future.": "Add this account to your dashboard to keep track of its balance, and use it as a bookmark in the future.", - "Add to dashboard": "Add to dashboard", - "Add to followed accounts": "Add to followed accounts", + "Add to bookmarks": "Add to bookmarks", "Add to list": "Add to list", "Added votes": "Added votes", "Additional fee": "Additional fee", @@ -126,7 +125,6 @@ "Filter votes": "Filter votes", "Final confirmation": "Final confirmation", "Follow": "Follow", - "Follow Account": "Follow Account", "Following": "Following", "Get passphrase": "Get passphrase", "Get to your Dashboard": "Get to your Dashboard", diff --git a/jest.config.js b/jest.config.js index e454e878cb..599b9f751c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -82,6 +82,11 @@ module.exports = { 'src/utils/proxyLogin.js', 'src/utils/rawTransactionWrapper.js', 'src/utils/to.js', + 'src/components/resultBox/resultBox.js', // FollowUp #1515 + 'src/components/passphraseSteps/index.js', // FollowUp #1515 + 'src/actions/peers.js', // FollowUp #1515 + 'src/components/send/steps/confirm/confirm.js', // FollowUp #1515 + 'src/components/sendNew/', ], coverageThreshold: { global: { diff --git a/src/components/followedAccounts/accountTitleInput.js b/src/components/followedAccounts/accountTitleInput.js index 717494fc29..9f5e1abda0 100644 --- a/src/components/followedAccounts/accountTitleInput.js +++ b/src/components/followedAccounts/accountTitleInput.js @@ -16,7 +16,6 @@ const AccountTitleInput = ({ autoFocus={true} disabled={disabled} onChange={val => onChange(val, validateInput)} - require={true} />; }; diff --git a/src/components/multiStep/index.js b/src/components/multiStep/index.js index 68453975fd..acb1df9c9d 100644 --- a/src/components/multiStep/index.js +++ b/src/components/multiStep/index.js @@ -82,15 +82,11 @@ class MultiStep extends React.Component { prevStep: step.prevStep, reset: this.reset.bind(this), ...step.data[step.current], + finalCallback, }; - if (step.current === (children.length - 1)) { - if (typeof finalCallback === 'function') { - extraProps.finalCallback = finalCallback; - } - } else { - extraProps.prevState = Object.assign({}, step.data[step.current + 1]); - } + extraProps.prevState = Object.assign({}, step.data[step.current + 1]); + return (
h2 { + font-size: 28px; + } + & footer { padding: 0px; + display: flex; + justify-content: center; + + & > button { + width: calc(100% / 2 - 20px); + margin: 0; + } } & p { color: var(--paragraph-color); line-height: var(--paragraph-line-height); + font-size: 16px; } & .copy { color: var(--copy-color); cursor: pointer; - } -} - -.okButton { - margin-bottom: 20px; -} - -@media (--small-viewport) { - .resultBox { - height: calc(100vh - 58px - 62px); /* stylelint-disable-line */ - } - - .icon { - margin-top: 40px; - } -} - -@media (--xSmall-viewport) { - .okButton { - width: 100%; + font-size: 16px; + font-weight: 600; } - .addFollowedAccountButton { - width: 100%; + & .okButton { + margin-left: 20px; } } diff --git a/src/components/resultBox/resultBox.js b/src/components/resultBox/resultBox.js index ac8efb16e3..3f70eb0f7c 100644 --- a/src/components/resultBox/resultBox.js +++ b/src/components/resultBox/resultBox.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Button } from '../toolbox/buttons/button'; +import { Button, ActionButton } from '../toolbox/buttons/button'; import { FontIcon } from '../fontIcon'; import CopyToClipboard from '../copyToClipboard'; @@ -45,29 +45,17 @@ class ResultBox extends React.Component {
diff --git a/src/components/resultBox/resultBox.test.js b/src/components/resultBox/resultBox.test.js index 016d2b335f..1ba67b87a8 100644 --- a/src/components/resultBox/resultBox.test.js +++ b/src/components/resultBox/resultBox.test.js @@ -117,6 +117,6 @@ describe('Result Box', () => { wrapper = mount(, options); - expect(wrapper).to.have.descendants('.add-follwed-account-button'); + expect(wrapper).to.have.descendants('.add-to-bookmarks'); }); }); diff --git a/src/components/send/index.js b/src/components/send/index.js index 7035e18ecd..e6cf45ccd4 100644 --- a/src/components/send/index.js +++ b/src/components/send/index.js @@ -105,4 +105,3 @@ class Send extends React.Component { } export default translate()(Send); - diff --git a/src/components/sendNew/index.js b/src/components/sendNew/index.js new file mode 100644 index 0000000000..0c3cd4e470 --- /dev/null +++ b/src/components/sendNew/index.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; + +// import { FontIcon } from '../fontIcon'; +import Box from '../box'; +import MultiStep from './../multiStep'; +import ResultBox from '../resultBox'; +import Form from './steps/form'; +import Confirm from './steps/confirm'; +import FollowAccount from '../sendTo/followAccount'; +import PassphraseSteps from './../passphraseSteps'; +import AccountInitialization from '../accountInitialization'; +import { parseSearchParams } from './../../utils/searchParams'; +import breakpoints from './../../constants/breakpoints'; +import styles from './send.css'; + +class Send extends React.Component { + constructor(props) { + super(props); + const needsAccountInit = !props.account.serverPublicKey + && props.account.balance > 0 + && props.pendingTransactions.length === 0; + + const { amount, recipient } = this.getSearchParams(); + this.state = { + isActiveOnMobile: !!recipient || !!amount || needsAccountInit, + isActiveTabSend: true, + }; + } + + getSearchParams() { + return parseSearchParams(this.props.history.location.search); + } + + setActiveOnMobile({ isActiveOnMobile, isActiveTabSend = true }) { + this.setState({ isActiveOnMobile, isActiveTabSend }); + } + + setActiveTabSend(isActiveTabSend) { + this.setState({ isActiveTabSend }); + } + + goToWallet() { + this.props.history.push('/wallet'); + } + + render() { + const { amount, recipient, reference } = this.getSearchParams(); + + return ( + +
+
+ + +
breakpoints.m} + address={recipient} + amount={amount} + reference={reference} + setTabSend={this.setActiveTabSend.bind(this)} + settingsUpdated={this.props.settingsUpdated} + settings={this.props.settings} + goToWallet={this.goToWallet.bind(this)} + /> + + + + + + +
+
+
+ ); + } +} + +const mapStateToProps = state => ({ + account: state.account, +}); + +export default connect(mapStateToProps)(translate()(Send)); + diff --git a/src/components/sendNew/send.css b/src/components/sendNew/send.css new file mode 100644 index 0000000000..93616264aa --- /dev/null +++ b/src/components/sendNew/send.css @@ -0,0 +1,82 @@ +@import '../app/variables'; + +.send { + position: relative; + background: linear-gradient(90deg, #fff, #f5f8fc); +} + +.mobileMenu, +.mobileClose { + display: none; +} + +@media (--medium-viewport) { + .send { + position: fixed; + right: 0; + height: 100vh; /* stylelint-disable-line */ + transition: top 300ms ease-in-out; + z-index: 10; + + &.isActive { + top: var(--m-top-bar-height); + + & .wrapper { + padding-bottom: 100px; + } + } + } + + .mobileClose { + display: block; + position: absolute; + top: 20px; + right: 20px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + } + + .mobileMenu { + display: block; + position: fixed; + bottom: 0; + left: 0; + z-index: 5; + } + + .mobileMenuItem { + display: inline-block; + padding: 20px; + font-weight: 600; + font-family: var(--heading-font); + cursor: pointer; + font-size: 24px; + } +} + +@media (--small-viewport) { + .send { + height: 100vh; /* stylelint-disable-line */ + + &.isActive { + top: var(--s-top-bar-height); + + & .wrapper { + padding-bottom: 50px; + } + } + } + + .mobileMenuItem { + font-size: 18px; + } +} + +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + justify-content: center; +} diff --git a/src/components/sendNew/steps/confirm/confirm.css b/src/components/sendNew/steps/confirm/confirm.css new file mode 100644 index 0000000000..1f3a8d5099 --- /dev/null +++ b/src/components/sendNew/steps/confirm/confirm.css @@ -0,0 +1,221 @@ +@import '../../../app/variables.css'; + +:root { + --tab-inactive-color: --color-grayscale-dark; + --font-size-L: 16px; + --font-size-XS: 14px; +} + +.wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + text-align: left; + margin: 113px 0 0; + padding: 0; + + & p { + font-size: var(--font-size-L); + line-height: 30px; + } + + & > form { + margin-top: 54px; + + & > div { + height: 82px; + + & input { + margin-top: 1px; + font-size: 16px; + font-weight: 600; + } + } + + & > div:first-child { + position: relative; + + & > label { + top: 0; + } + + & > div { + position: absolute; + top: 24px; + } + } + + & > div:nth-child(2) { + margin-top: 25px; + + & textarea { + font-weight: 600; + color: var(--color-grayscale-dark); + } + } + + & label { + font-weight: 600; + } + + & input { + font-weight: 600; + } + } + + & > footer { + width: 100%; + margin: 23px 0 0; + + & > div { + height: 80px; + + & input { + margin-top: 1px; + } + } + + & > section { + width: 100%; + } + + & > section > button { + width: 160px; + height: 56px; + } + + & > section > button:last-child { + margin-left: 22px; + height: 57px; + } + } +} + +.recepientRow { + display: flex; +} + +.disabled > .inputElement { + border-bottom: none; + color: #212121; + overflow: auto; + resize: none; +} + +.fee { + text-align: right; + margin-top: -12px; + margin-bottom: 24px; + font-size: 12px; + line-height: 14px; + color: grey; +} + +input:read-only { + border-bottom: solid 1px var(--color-white) !important; +} + +.disabledInput label { + font-size: 14px; +} + +.button { + min-width: 0; + width: 100%; +} + +.headerWrapper { + padding: 0 !important; + margin: 0; + + & .title { + float: left; + } + + & .account { + float: right; + } + + & h2 { + margin: 0; + } + + & h3 { + display: inline-block; + margin: 0; + } + + & .balanceUnit { + font-weight: var(--font-weight-bold); + } + + & .address { + text-align: right; + cursor: pointer; + } + + & .transfer { + color: var(--color-grayscale-dark); + } +} + +.accountVisual { + margin-right: 10px; +} + +.text { + color: #3c5068; + font-size: 16px; + font-weight: 600; +} + +.smallAddress { + margin-top: 9px; + font-size: 12px; + font-weight: 600; +} + +.address { + font-size: 16px; + font-weight: 600; + padding-top: 10px; +} + +.sendTo { + display: flex; + align-items: flex-start; + flex-direction: row; +} + +.disabledInput input:disabled { + opacity: 1; + border: none; +} + +.subTitle { + font-size: 14px; +} + +@media (--small-viewport) { + .headerWrapper { + display: none; + } + + .header { + height: 60px; + } + + .accountVisual { + margin: 24px 0; + } +} + +@media (--xSmall-viewport) { + .wrapper { + & p { + font-size: var(--font-size-L); + } + } +} diff --git a/src/components/sendNew/steps/confirm/confirm.js b/src/components/sendNew/steps/confirm/confirm.js new file mode 100644 index 0000000000..668cd8eb42 --- /dev/null +++ b/src/components/sendNew/steps/confirm/confirm.js @@ -0,0 +1,197 @@ +import React from 'react'; +import { fromRawLsk, toRawLsk } from '../../../../utils/lsk'; +import AccountVisual from '../../../accountVisual'; +import { Button, PrimaryButton } from './../../../toolbox/buttons/button'; +import ToolBoxInput from '../../../toolbox/inputs/toolBoxInput'; +import fees from './../../../../constants/fees'; +import styles from './confirm.css'; + +class Confirm extends React.Component { + constructor() { + super(); + this.state = { + recipient: { + value: '', + }, + amount: { + value: '', + }, + reference: { + value: '', + }, + loading: false, + }; + this.fee = fees.send; + } + + componentDidMount() { + const recipient = this.props.accountInit ? this.props.account.address : this.props.recipient; + const amount = this.props.accountInit ? 0.1 : this.props.amount; + const newState = { + recipient: { + value: recipient || '', + }, + amount: { + value: amount || '', + }, + reference: { + value: this.props.reference || '', + }, + }; + this.setState(newState); + } + + componentDidUpdate() { + // Hardware wallet code preventing by going on last step when there is pending transaction + const pending = this.props.account.hwInfo && this.props.account.hwInfo.deviceId + ? this.props.pendingTransactions.find(transaction => ( + transaction.senderId === this.props.account.address && + transaction.recipientId === this.state.recipient.value && + fromRawLsk(transaction.amount) === this.props.amount + )) : this.props.pendingTransactions.length; + + if (this.state.loading && (pending || this.props.failedTransactions)) { + const data = this.getTransactionState(); + this.props.nextStep({ + ...data, + amount: this.props.amount, + account: this.props.account, + recipientId: this.state.recipient.value, + }); + this.setState({ loading: false }); + } + } + + getTransactionState() { + const success = this.props.pendingTransactions.length > 0 && !this.props.failedTransactions; + const successMessage = this.props.t('Transaction is being processed and will be confirmed. It may take up to 15 minutes to be secured in the blockchain.'); + const failureMessage = this.props.failedTransactions ? this.props.failedTransactions.errorMessage : ''; + const copy = success ? { + title: this.props.t('Copy Transaction ID to clipboard'), + value: this.props.pendingTransactions[0].id, + } : null; + return { + title: success ? this.props.t('Thank you') : this.props.t('Sorry'), + body: success ? successMessage : failureMessage, + copy, + success, + }; + } + + handleChange(name, value, error) { + this.setState({ + [name]: { + value, + error: typeof error === 'string' ? error : '', + }, + }); + } + + send(event) { + event.preventDefault(); + this.setState({ loading: true }); + this.props.sent({ + account: this.props.account, + recipientId: this.state.recipient.value, + amount: this.state.amount.value, + passphrase: this.props.passphrase.value, + secondPassphrase: this.props.secondPassphrase.value, + data: this.props.accountInit ? this.props.t('Account initialization') : this.props.reference, + }); + } + + addAmountAndFee() { + return fromRawLsk(toRawLsk(this.state.amount.value) + this.fee); + } + + render() { + const followedAccount = this.props.followedAccounts + .find(account => account.address === this.state.recipient.value); + return ( +
+
+

{this.props.t('Send LSK')}

+
+ {this.props.accountInit + ?
+

{this.props.t('You will send a small amount of {{fee}} LSK to yourself and therefore initialize your ID.', { fee: fromRawLsk(fees.send) })}

+

{this.props.t('You only need to do this once for each Lisk ID.')}

+
+ : + +
+ +
+
{followedAccount && followedAccount.title}
+
+ {this.state.recipient.value} +
+
+
+
+ {this.state.reference.value ? + : + + } + + + } +
+
+
+
{this.props.t('Transactions can’t be reversed')}
+
+
+ ); + } +} + +export default Confirm; diff --git a/src/components/sendNew/steps/confirm/confirm.test.js b/src/components/sendNew/steps/confirm/confirm.test.js new file mode 100644 index 0000000000..4e15f1d1a6 --- /dev/null +++ b/src/components/sendNew/steps/confirm/confirm.test.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import configureStore from 'redux-mock-store'; +import PropTypes from 'prop-types'; + +import accounts from '../../../../../test/constants/accounts'; +import i18n from '../../../../i18n'; +import Confirm from './confirm'; + +const fakeStore = configureStore(); + +describe('Confirm Component', () => { + let wrapper; + const account = accounts.delegate; + const props = { + account, + pendingTransactions: [], + passphrase: { value: account.passphrase }, + secondPassphrase: { value: null }, + closeDialog: () => {}, + sent: sinon.spy(), + t: key => key, + nextStep: () => {}, + followedAccounts: [], + }; + + describe('Without account init', () => { + beforeEach(() => { + account.serverPublicKey = 'public_key'; + const store = fakeStore({ account }); + + wrapper = mount(, { + context: { store, i18n }, + childContextTypes: { + store: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, + }, + }); + }); + + it('renders two Input components', () => { + expect(wrapper.find('Input')).to.have.length(3); + }); + + it('renders two Button component', () => { + expect(wrapper.find('Button')).to.have.length(2); + }); + + it('allows to send a transaction', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '120.25' } }); + wrapper.find('.recipient input').simulate('change', { target: { value: '11004588490103196952L' } }); + wrapper.find('.send-button button').simulate('click'); + expect(props.sent).to.have.been.calledWith({ + account: props.account, + amount: '120.25', + data: undefined, + passphrase: props.account.passphrase, + recipientId: '11004588490103196952L', + secondPassphrase: null, + }); + }); + }); + + describe('With account init', () => { + beforeEach(() => { + props.address = '123L'; + props.amount = 1; + props.accountInit = true; + const store = fakeStore({ account }); + + wrapper = mount(, { + context: { store, i18n }, + childContextTypes: { + store: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, + }, + }); + }); + + it('account initialisation option should not conflict with launch protocol', () => { + expect(wrapper.state('recipient').value).to.equal(account.address); + expect(wrapper.state('amount').value).to.equal(0.1); + }); + }); +}); diff --git a/src/components/sendNew/steps/confirm/index.js b/src/components/sendNew/steps/confirm/index.js new file mode 100644 index 0000000000..7034a963e6 --- /dev/null +++ b/src/components/sendNew/steps/confirm/index.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; + +import { sent } from '../../../../actions/transactions'; +import Confirm from './confirm'; + +const mapStateToProps = state => ({ + account: state.account, + pendingTransactions: state.transactions.pending, + failedTransactions: state.transactions.failed, + followedAccounts: state.followedAccounts ? state.followedAccounts.accounts : [], +}); + +const mapDispatchToProps = dispatch => ({ + sent: data => dispatch(sent(data)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(translate()(Confirm)); diff --git a/src/components/sendNew/steps/confirm/index.test.js b/src/components/sendNew/steps/confirm/index.test.js new file mode 100644 index 0000000000..af3cb35a0d --- /dev/null +++ b/src/components/sendNew/steps/confirm/index.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import i18n from '../../../../i18n'; +import ConfirmHOC from './index'; + +describe('ConfirmHOC', () => { + let wrapper; + const store = {}; + const account = {}; + const transactions = { pending: [] }; + beforeEach(() => { + store.getState = () => ({ + account, + transactions, + }); + store.subscribe = () => {}; + store.dispatch = () => {}; + wrapper = mount(); + }); + + it('should render Send', () => { + expect(wrapper.find('Confirm')).to.have.lengthOf(1); + }); + + it('should mount Send with appropriate properties', () => { + const props = wrapper.find('Confirm').props(); + expect(props.account).to.be.equal(account); + expect(typeof props.sent).to.be.equal('function'); + }); +}); diff --git a/src/components/sendNew/steps/confirm/input.css b/src/components/sendNew/steps/confirm/input.css new file mode 100644 index 0000000000..02add802c9 --- /dev/null +++ b/src/components/sendNew/steps/confirm/input.css @@ -0,0 +1,3 @@ +.input { + padding: 0px !important; +} diff --git a/src/components/sendNew/steps/form/form.css b/src/components/sendNew/steps/form/form.css new file mode 100644 index 0000000000..a73b10aeda --- /dev/null +++ b/src/components/sendNew/steps/form/form.css @@ -0,0 +1,154 @@ +@import '../../../app/variables.css'; + +:root { + --tab-inactive-color: var(--color-grayscale-dark); + --grid-padding-left: 30px; + --header-line-height: 36px; + --header-subtitle-font-size: var(--subtitle-font-size); + --font-weight-bold: 500; + --fee-color: var(--color-grayscale-dark); +} + +.form { + position: relative; + margin-top: 54px; + + & > div { + height: 82px; + + & input { + margin-top: 1px; + font-size: 16px; + font-weight: 600; + } + + & > label { + font-size: 14px; + font-weight: 600px !important; + } + } + + & input { + border-radius: 0; + } +} + +.setMaxAmount { + position: absolute; + right: 0px; + bottom: 30px; + cursor: pointer; + font-size: 14px; + color: var(--color-primary-medium); + font-weight: var(--font-weight-bold); +} + +.fee { + text-align: right; + margin-bottom: 24px; + font-size: 14px; + font-weight: 600; + position: absolute; + right: 0; + top: 70px; + color: var(--fee-color); +} + +input:read-only { + border-bottom: solid 1px var(--color-white) !important; +} + +.button { + min-width: 0; + width: 100%; +} + +.accountVisual { + position: absolute; + left: -40px; + top: -15px; +} + +.headerWrapper { + padding: 0 !important; + margin: 0; + line-height: var(--header-line-height); + + & .title { + float: left; + } + + & .subTitle { + font-size: var(--header-subtitle-font-size); + } + + & .account { + float: right; + } + + & h2 { + font-weight: var(--font-weight-bold); + margin: 0; + } + + & h3 { + display: inline-block; + margin: 0; + } + + & .balanceUnit { + font-weight: var(--font-weight-bold); + } + + & .address { + text-align: right; + cursor: pointer; + } + + & .transfer { + color: var(--color-grayscale-dark); + } +} + +.sendWrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + height: 100%; + box-sizing: border-box; + text-align: left; + margin-top: 115px; + + & > footer { + width: 100%; + margin: 50px 0 0; + + & > button { + width: 160px; + height: 56px; + } + + & > button:last-child { + margin-left: 22px; + height: 57px; + } + } +} + +.error { + display: block; + padding-right: 85px; +} + +@media (--small-viewport) { + .headerWrapper { + display: none; + } +} + +@media (--xSmall-viewport) { + .nextButton { + width: 100%; + } +} diff --git a/src/components/sendNew/steps/form/form.js b/src/components/sendNew/steps/form/form.js new file mode 100644 index 0000000000..089e77ff0e --- /dev/null +++ b/src/components/sendNew/steps/form/form.js @@ -0,0 +1,188 @@ +import React from 'react'; +import { fromRawLsk } from '../../../../utils/lsk'; +import { Button, ActionButton } from '../../../toolbox/buttons/button'; +import { authStatePrefill } from '../../../../utils/form'; +import Converter from '../../../converter'; +import fees from '../../../../constants/fees'; +import styles from './form.css'; +import regex from '../../../../utils/regex'; +import AddressInput from '../../../addressInput'; +import ReferenceInput from '../../../referenceInput'; +import Bookmark from '../../../bookmark'; + +class Form extends React.Component { + constructor(props) { + super(props); + this.state = { + recipient: { + value: this.props.address || '', + }, + amount: { + value: this.props.amount || '', + }, + reference: { + value: this.props.reference || '', + }, + openFollowedAccountSuggestion: true, + ...authStatePrefill(), + }; + this.fee = fees.send; + this.inputValidationRegexps = { + recipient: regex.address, + amount: regex.amount, + }; + } + + componentDidMount() { + if (this.props.prevState) { + const newState = ['recipient', 'amount', 'reference'].reduce((entries, name) => { + const value = this.props.prevState[name] || this.state[name].value; + return { + ...entries, + [name]: { + value, + error: value ? this.validateInput(name, value, true) : undefined, + }, + }; + }, {}); + this.setState({ ...newState, ...authStatePrefill(this.props.account) }); + } + } + + handleChange(name, required = true, value, error) { + this.setState({ + [name]: { + value, + error: typeof error === 'string' ? error : this.validateInput(name, value, required), + }, + }); + } + + validateInput(name, value, required) { // eslint-disable-line + const byteCount = encodeURI(value).split(/%..|./).length - 1; + if (!value && required) { + return this.props.t('Required'); + } else if (name === 'reference' && byteCount > 64) { + return this.props.t('Maximum length exceeded'); + } else if (!value.match(this.inputValidationRegexps[name])) { + return name === 'amount' ? this.props.t('Invalid amount') : this.props.t('Invalid address'); + } else if (name === 'amount' && value > parseFloat(this.getMaxAmount())) { + return this.props.t('Not enough LSK'); + } else if (name === 'amount' && value === '0') { + return this.props.t('Zero not allowed'); + } + return undefined; + } + + getMaxAmount() { + return fromRawLsk(Math.max(0, this.props.account.balance - this.fee)); + } + + handleSetMaxAmount() { + const amount = parseFloat(this.getMaxAmount()); + this.setState({ + amount: { + value: amount.toString(), + }, + }); + } + + focusReference() { + this.referenceInput.focus(); + } + + handleFocus() { + this.setState({ + showSetMaxAmount: true, + }); + } + + handleBlur() { + /* when click on set max amount link we need a small delay */ + /* to process the click event before hiding */ + setTimeout(() => { + this.setState({ + showSetMaxAmount: false, + }); + }, 200); + } + + render() { + return ( +
+
+

{this.props.t('Send LSK')}

+
+
+ { this.props.followedAccounts.length > 0 && this.state.openFollowedAccountSuggestion ? + : + + } + +
+ + { + this.state.showSetMaxAmount && + !this.state.amount.value && + this.getMaxAmount() > 0 ? + { this.props.t('Set max. amount') } + :
+ } +
+ +
+ + + this.props.nextStep({ + recipient: this.state.recipient.value, + amount: this.state.amount.value, + reference: this.state.reference.value, + })} + disabled={(!!this.state.recipient.error || + !this.state.recipient.value || + !!this.state.amount.error || + !!this.state.reference.error || + !this.state.amount.value)} + className={`send-next-button ${styles.nextButton}`} + > + {this.props.t('Next')} + +
+
+ ); + } +} + +export default Form; diff --git a/src/components/sendNew/steps/form/form.test.js b/src/components/sendNew/steps/form/form.test.js new file mode 100644 index 0000000000..44bfa666d2 --- /dev/null +++ b/src/components/sendNew/steps/form/form.test.js @@ -0,0 +1,147 @@ +import React from 'react'; +import { expect } from 'chai'; +import { useFakeTimers } from 'sinon'; +import { mount } from 'enzyme'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import PropTypes from 'prop-types'; +import accounts from '../../../../../test/constants/accounts'; +import i18n from '../../../../i18n'; +import Form from './form'; + +describe('Form Component', () => { + let wrapper; + let props; + let clock; + + beforeEach(() => { + const account = accounts.delegate; + + clock = useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'Date', 'setInterval'], + }); + + const priceTicker = { + success: true, + LSK: { + USD: 1, + }, + }; + + const store = configureMockStore([thunk])({ + account, + settings: {}, + settingsUpdated: () => {}, + liskService: { priceTicker }, + }); + + props = { + account, + pendingTransactions: [], + closeDialog: () => {}, + t: key => key, + nextStep: () => {}, + history: { location: { search: '' } }, + followedAccounts: { accounts: [{ address: '123L', title: 'test' }] }, + }; + wrapper = mount(
, { + context: { store, i18n }, + childContextTypes: { + store: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, + }, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('renders three Input components', () => { + expect(wrapper.find('Input')).to.have.length(3); + }); + + it('renders one Button component', () => { + expect(wrapper.find('Button')).to.have.length(2); + }); + + it('accepts valid amount', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '120.25' } }); + expect(wrapper.find('Input.amount').text()).to.not.contain('Invalid amount'); + }); + + it('recognizes invalid amount', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '120 INVALID' } }); + expect(wrapper.find('Input.amount').text()).to.contain('Invalid amount'); + }); + + it('recognizes zero amount', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '0' } }); + expect(wrapper.find('Input.amount').text()).to.contain('Zero not allowed'); + }); + + it('recognizes too high amount', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '12000' } }); + expect(wrapper.find('Input.amount').text()).to.contain('Not enough LSK'); + }); + + it('recognizes empty amount', () => { + wrapper.find('.amount input').simulate('change', { target: { value: '12000' } }); + wrapper.find('.amount input').simulate('change', { target: { value: '' } }); + expect(wrapper.find('Input.amount').text()).to.contain('Required'); + }); + + it('accepts valid recipient', () => { + wrapper.find('.recipient input').simulate('change', { target: { value: '11004588490103196952L' } }); + expect(wrapper.find('Input.recipient').text()).to.not.contain('Invalid address'); + }); + + it('recognizes invalid recipient', () => { + wrapper.find('.recipient input').simulate('change', { target: { value: '11004588490103196952' } }); + expect(wrapper.find('Input.recipient').text()).to.contain('Invalid address'); + }); + + it('recognizes too big reference length', () => { + wrapper.find('.reference input').simulate('change', { target: { value: 'test'.repeat(100) } }); + expect(wrapper.find('Input.reference').text()).to.contain('Maximum length exceeded'); + }); + + it('displays bookmark', () => { + const account = accounts.delegate; + const followedAccounts = { accounts: [{ address: '123L', title: '123' }] }; + + const store = configureMockStore([thunk])({ + account, + settings: {}, + settingsUpdated: () => {}, + followedAccounts, + }); + props.followedAccounts = followedAccounts.accounts; + wrapper = mount(, { + context: { store, i18n }, + childContextTypes: { + store: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, + }, + }); + + expect(wrapper.find('Bookmark')).to.have.length(1); + }); + + it('Shows the Set max. amount link on amount focus', () => { + wrapper.find('.amount input').simulate('focus'); + expect(wrapper.state('showSetMaxAmount')).to.equal(true); + }); + + it('Puts max amount into input field', () => { + wrapper.find('.amount input').simulate('focus'); + wrapper.find('.set-max-amount').simulate('click'); + expect(wrapper.state('amount').value).to.equal('999.9'); + }); + + it('Hides the Set max. amount link on amount blur', () => { + wrapper.find('.amount input').simulate('blur'); + clock.tick(1200); + expect(wrapper.state('showSetMaxAmount')).to.equal(false); + }); +}); diff --git a/src/components/sendNew/steps/form/index.js b/src/components/sendNew/steps/form/index.js new file mode 100644 index 0000000000..b2ff28193c --- /dev/null +++ b/src/components/sendNew/steps/form/index.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; + +import Form from './form'; + +const mapStateToProps = state => ({ + account: state.account, + pendingTransactions: state.transactions.pending, + followedAccounts: state.followedAccounts.accounts, +}); + +export default connect(mapStateToProps)(translate()(Form)); diff --git a/src/components/sendNew/steps/form/index.test.js b/src/components/sendNew/steps/form/index.test.js new file mode 100644 index 0000000000..cb117c1028 --- /dev/null +++ b/src/components/sendNew/steps/form/index.test.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { MemoryRouter as Router } from 'react-router-dom'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import i18n from '../../../../i18n'; +import FormHOC from './index'; + +describe('FormHOC', () => { + let wrapper; + const store = {}; + const peers = { + data: {}, + status: true, + }; + const account = {}; + const transactions = { pending: [] }; + + const priceTicker = { + success: true, + LSK: { + USD: 1, + }, + }; + + beforeEach(() => { + store.getState = () => ({ + peers, + account, + transactions, + settings: {}, + liskService: { priceTicker }, + followedAccounts: { accounts: [] }, + }); + store.subscribe = () => {}; + store.dispatch = () => {}; + wrapper = mount( + + + + ); + }); + + it('should render Send', () => { + expect(wrapper.find('Form')).to.have.lengthOf(1); + }); +}); diff --git a/src/components/sendNew/steps/form/stories.js b/src/components/sendNew/steps/form/stories.js new file mode 100644 index 0000000000..0d19c1db87 --- /dev/null +++ b/src/components/sendNew/steps/form/stories.js @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Provider } from 'react-redux'; +import { storiesOf } from '@storybook/react'; + +import Form from './form'; + +import accounts from '../../../../../test/constants/accounts'; +import store from '../../../../store'; + +const account = accounts.genesis; + +storiesOf('Form', module) + .addDecorator(getStory => ( + + {getStory()} + + )) + .add('pre-filled recipient and amount', () => ( + + )) + .add('without pre-filles', () => ( + + )); diff --git a/src/components/sendTo/followAccount.css b/src/components/sendTo/followAccount.css index 5b29c305f8..7a9bb7a214 100644 --- a/src/components/sendTo/followAccount.css +++ b/src/components/sendTo/followAccount.css @@ -4,10 +4,11 @@ line-height: 36px; display: flex; justify-content: center; - background: var(--gradient-greyscale); + background: none; box-shadow: none; border-top-right-radius: 0px; border-bottom-right-radius: 0px; + text-align: left; & header { & p { @@ -31,12 +32,22 @@ & .follow { margin-top: 25px; - height: 56px; + height: 58px; & .label { vertical-align: middle; } } + + & > footer { + display: flex; + justify-content: space-evenly; + + & > button { + width: calc(100% / 2 - 20px) !important; + margin-top: 0 !important; + } + } } .followedAccountsStep { diff --git a/src/components/sendTo/followAccount.js b/src/components/sendTo/followAccount.js index aeb3892377..6b0ef8c7d7 100644 --- a/src/components/sendTo/followAccount.js +++ b/src/components/sendTo/followAccount.js @@ -32,7 +32,7 @@ class FollowAccount extends React.Component { return (
-

{t('Follow Account')}

+

{t('Add to bookmarks')}

{t('Add this account to your dashboard to keep track of its balance, and use it as a bookmark in the future.')}

); diff --git a/src/constants/routes.js b/src/constants/routes.js index eea395ab56..360227a523 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -16,6 +16,7 @@ import HwWallet from '../components/hwWallet'; // import NotFound from '../components/notFound'; import AccountVisualDemo from '../components/accountVisual/demo'; import Receive from '../components/receive'; +import Send from '../components/sendNew'; export default { accountVisualDemo: { @@ -32,10 +33,15 @@ export default { path: '/wallet', component: TransactionDashboard, isPrivate: true, + exact: true, }, request: { - path: '/request', + path: '/wallet/request', component: Receive, + }, + send: { + path: '/wallet/send', + component: Send, isPrivate: true, }, delegates: {