diff --git a/src/components/header/headerElement.js b/src/components/header/headerElement.js index 4eae4b5a9..faf6f3064 100644 --- a/src/components/header/headerElement.js +++ b/src/components/header/headerElement.js @@ -5,6 +5,7 @@ import logo from '../../assets/images/LISK-nano.png'; import styles from './header.css'; import VerifyMessage from '../signVerify/verifyMessage'; import SignMessage from '../signVerify/signMessage'; +import RegisterDelegate from '../registerDelegate'; import Send from '../send'; import PrivateWrapper from '../privateWrapper'; import SecondPassphraseMenu from '../secondPassphrase'; @@ -20,8 +21,16 @@ const HeaderElement = props => ( menuRipple theme={styles} > + { + !props.account.isDelegate && + props.setActiveDialog({ + title: 'Register as delegate', + childComponent: RegisterDelegate, + })} + /> + } - props.setActiveDialog({ diff --git a/src/components/header/headerElement.test.js b/src/components/header/headerElement.test.js index f21e99c40..49924661f 100644 --- a/src/components/header/headerElement.test.js +++ b/src/components/header/headerElement.test.js @@ -18,9 +18,10 @@ describe('HeaderElement', () => { beforeEach(() => { const mockInputProps = { setActiveDialog: () => { }, + account: {}, }; propsMock = sinon.mock(mockInputProps); - wrapper = shallow(); + wrapper = shallow(); }); afterEach(() => { diff --git a/src/components/passphrase/steps.js b/src/components/passphrase/steps.js index e6b8321bf..5f5ed9012 100644 --- a/src/components/passphrase/steps.js +++ b/src/components/passphrase/steps.js @@ -1,19 +1,19 @@ -export default ({ props, state, setState }) => ({ +export default context => ({ info: { cancelButton: { title: 'cancel', - onClick: () => { props.closeDialog(); }, + onClick: () => { context.props.closeDialog(); }, }, confirmButton: { title: () => 'next', - fee: () => props.fee, - onClick: () => { setState({ current: 'generate' }); }, + fee: () => context.props.fee, + onClick: () => { context.setState({ current: 'generate' }); }, }, }, generate: { cancelButton: { title: 'cancel', - onClick: () => { props.closeDialog(); }, + onClick: () => { context.props.closeDialog(); }, }, confirmButton: { title: () => 'Next', @@ -24,26 +24,26 @@ export default ({ props, state, setState }) => ({ show: { cancelButton: { title: 'cancel', - onClick: () => { props.closeDialog(); }, + onClick: () => { context.props.closeDialog(); }, }, confirmButton: { title: () => 'Yes! It\'s safe', fee: () => {}, - onClick: () => { setState({ current: 'confirm' }); }, + onClick: () => { context.setState({ current: 'confirm' }); }, }, }, confirm: { cancelButton: { title: 'Back', - onClick: () => { setState({ current: 'show' }); }, + onClick: () => { context.setState({ current: 'show' }); }, }, confirmButton: { - title: () => (props.confirmButton || 'Login'), + title: () => (context.props.confirmButton || 'Login'), fee: () => {}, onClick: () => { - props.onPassGenerated(state.passphrase); - if (!props.keepModal) { - props.closeDialog(); + context.props.onPassGenerated(context.state.passphrase); + if (!context.props.keepModal) { + context.props.closeDialog(); } }, }, diff --git a/src/components/registerDelegate/index.js b/src/components/registerDelegate/index.js new file mode 100644 index 000000000..4ffeb88a6 --- /dev/null +++ b/src/components/registerDelegate/index.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import RegisterDelegate from './registerDelegate'; +import { accountUpdated } from '../../actions/account'; +import { transactionAdded } from '../../actions/transactions'; +import { successAlertDialogDisplayed, errorAlertDialogDisplayed } from '../../actions/dialog'; + +const mapStateToProps = state => ({ + account: state.account, + peers: state.peers, +}); + +const mapDispatchToProps = dispatch => ({ + onAccountUpdated: data => dispatch(accountUpdated(data)), + showSuccessAlert: data => dispatch(successAlertDialogDisplayed(data)), + showErrorAlert: data => dispatch(errorAlertDialogDisplayed(data)), + addTransaction: data => dispatch(transactionAdded(data)), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(RegisterDelegate); diff --git a/src/components/registerDelegate/index.test.js b/src/components/registerDelegate/index.test.js new file mode 100644 index 000000000..3b9feade8 --- /dev/null +++ b/src/components/registerDelegate/index.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import store from '../../store'; +import RegisterDelegate from './index'; + +describe('RegisterDelegate HOC', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount( {}} />); + }); + + it('should render RegisterDelegate', () => { + expect(wrapper.find('RegisterDelegate')).to.have.lengthOf(1); + }); + + it('should mount registerDelegate with appropriate properties', () => { + const props = wrapper.find('RegisterDelegate').props(); + expect(typeof props.closeDialog).to.be.equal('function'); + }); +}); diff --git a/src/components/registerDelegate/registerDelegate.js b/src/components/registerDelegate/registerDelegate.js new file mode 100644 index 000000000..d33751f3b --- /dev/null +++ b/src/components/registerDelegate/registerDelegate.js @@ -0,0 +1,90 @@ +import React from 'react'; +import Input from 'react-toolbox/lib/input'; +import InfoParagraph from '../infoParagraph'; +import ActionBar from '../actionBar'; +import { registerDelegate } from '../../utils/api/delegate'; +import Fees from '../../constants/fees'; + +class RegisterDelegate extends React.Component { + constructor() { + super(); + + this.state = { + name: '', + nameError: '', + }; + } + + changeHandler(name, value) { + this.setState({ [name]: value }); + } + + register(username, secondSecret) { + registerDelegate(this.props.peers.data, username, + this.props.account.passphrase, secondSecret) + .then((data) => { + this.props.showSuccessAlert({ + text: `Delegate registration was successfully submitted with username: "${this.state.name}". It can take several seconds before it is processed.`, + }); + + // add to pending transaction + this.props.addTransaction({ + id: data.transactionId, + senderPublicKey: this.props.account.publicKey, + senderId: this.props.account.address, + amount: 0, + fee: Fees.registerDelegate, + }); + }) + .catch((error) => { + if (error && error.message === 'Username already exists') { + this.setState({ nameError: error.message }); + } else { + this.props.showErrorAlert({ + text: error && error.message ? `${error.message}.` : 'An error occurred while registering as delegate.', + }); + } + }); + } + + render() { + return ( +
+ + { + this.props.account.secondSignature && + + } +
+ + Becoming a delegate requires registration. You may choose your own + delegate name, which can be used to promote your delegate. Only the + top 101 delegates are eligible to forge. All fees are shared equally + between the top 101 delegates. + + +
+ ); + } +} + +export default RegisterDelegate; diff --git a/src/components/registerDelegate/registerDelegate.test.js b/src/components/registerDelegate/registerDelegate.test.js new file mode 100644 index 000000000..c16dfb093 --- /dev/null +++ b/src/components/registerDelegate/registerDelegate.test.js @@ -0,0 +1,116 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import { mount } from 'enzyme'; +import chaiEnzyme from 'chai-enzyme'; +import sinon from 'sinon'; +import Lisk from 'lisk-js'; +import { Provider } from 'react-redux'; +import store from '../../store'; +import RegisterDelegate from './registerDelegate'; +import * as delegateApi from '../../utils/api/delegate'; + +chai.use(chaiEnzyme()); + +const normalAccount = { + isDelegate: false, + address: '16313739661670634666L', + balance: 1000e8, +}; + +const delegateAccount = { + isDelegate: true, + address: '16313739661670634666L', + balance: 1000e8, + delegate: { + username: 'lisk-nano', + }, +}; + +const withSecondSecretAccount = { + address: '16313739661670634666L', + balance: 1000e8, + delegate: { + username: 'lisk-nano', + }, + secondSignature: 1, +}; + +const props = { + peers: { + data: Lisk.api({ + name: 'Custom Node', + custom: true, + address: 'http://localhost:4000', + testnet: true, + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }), + }, + closeDialog: () => {}, + onAccountUpdated: () => {}, + showSuccessAlert: () => {}, + showErrorAlert: () => {}, +}; + +const delegateProps = { ...props, account: delegateAccount }; +const normalProps = { ...props, account: normalAccount }; +const withSecondSecretProps = { ...props, account: withSecondSecretAccount }; + +describe('RegisterDelegate', () => { + let wrapper; + let delegateApiMock; + + beforeEach(() => { + delegateApiMock = sinon.mock(delegateApi); + }); + + afterEach(() => { + delegateApiMock.verify(); + delegateApiMock.restore(); + }); + + describe('Ordinary account', () => { + beforeEach(() => { + wrapper = mount(); + }); + + it('renders an InfoParagraph components', () => { + expect(wrapper.find('InfoParagraph')).to.have.length(1); + }); + + it('renders one Input component for a normal account', () => { + expect(wrapper.find('Input')).to.have.length(1); + }); + + it.skip('allows register as delegate for a non delegate account', () => { + wrapper.find('.username input').simulate('change', { target: { value: 'sample_username' } }); + expect(wrapper.find('.primary-button button').props().disabled).to.not.equal(true); + }); + }); + + describe('Ordinary account with second secret', () => { + beforeEach(() => { + wrapper = mount( + ); + }); + + it('renders two Input component for a an account with second secret', () => { + expect(wrapper.find('Input')).to.have.length(2); + }); + + it('allows register as delegate for a non delegate account with second secret', () => { + wrapper.find('.username input').simulate('change', { target: { value: 'sample_username' } }); + wrapper.find('.second-secret input').simulate('change', { target: { value: 'sample phrase' } }); + }); + }); + + describe('Delegate account', () => { + beforeEach(() => { + wrapper = mount(); + }); + + it('does not allow register as delegate for a delegate account', () => { + wrapper.find('.username input').simulate('change', { target: { value: 'sample_username' } }); + expect(wrapper.find('.primary-button button').props().disabled).to.be.equal(true); + }); + }); +}); diff --git a/src/utils/passphrase.test.js b/src/utils/passphrase.test.js index 80d0f9860..e6f7ba864 100644 --- a/src/utils/passphrase.test.js +++ b/src/utils/passphrase.test.js @@ -1,12 +1,9 @@ -import chai, { expect } from 'chai'; -import sinonChai from 'sinon-chai'; +import { expect } from 'chai'; import { generateSeed, generatePassphrase, isValidPassphrase } from './passphrase'; if (global._bitcore) delete global._bitcore; const mnemonic = require('bitcore-mnemonic'); -chai.use(sinonChai); - const randoms = [ 0.35125316992864564, 0.6836880327771695, 0.05720201294124072, 0.7136064360838184, 0.7655709865481362, 0.9670469669099078, 0.6699998930954159, 0.4377283727720742,